diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c06dcc5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09121234567\"}')", + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09121234567\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09131111111\"}')", + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/verify/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09131111111\",\"code\":\"0000\",\"full_name\":\"X\",\"role\":\"student\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"+989141111111\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"12345\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09223740993\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(curl -sSv --max-time 15 https://api.sms.ir)", + "Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09223740993\"}' -w '\\\\nHTTP %{http_code}\\\\n')" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c3fd20 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.gitignore +.gitattributes +.env +.env.local +__pycache__ +*.pyc +*.pyo +.venv +venv +.pytest_cache +.mypy_cache +node_modules +.vscode +.idea +*.sqlite3 +.DS_Store +README.md +locale/*/LC_MESSAGES/*.mo +staticfiles +media diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b89357e --- /dev/null +++ b/.env.example @@ -0,0 +1,53 @@ +DJANGO_SECRET_KEY=dev-only-change-me-in-prod +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 +DJANGO_SETTINGS_MODULE=ilo.settings.dev + +POSTGRES_DB=ilo +POSTGRES_USER=ilo +POSTGRES_PASSWORD=ilo +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +# SMS backend: dotted path. ConsoleSMSBackend prints OTPs to logs (dev only). +# For production with sms.ir, switch to SMSIRBackend and fill the SMSIR_* vars. +SMS_BACKEND=apps.accounts.services.sms.ConsoleSMSBackend +SMSIR_API_KEY= +SMSIR_LINE_NUMBER= +SMSIR_TEMPLATE_ID= +SMSIR_TEMPLATE_PARAM_NAME=Code +SMSIR_BASE_URL=https://api.sms.ir + +# Media storage. Default = local filesystem (writes to MEDIA_ROOT). +# To use S3-compatible (MinIO / ArvanCloud Object Storage), set: +# DEFAULT_FILE_STORAGE=storages.backends.s3.S3Storage +# and add `django-storages[s3]` to requirements.txt, then configure: +# AWS_S3_ENDPOINT_URL=https://s3.your-host +# AWS_ACCESS_KEY_ID=... +# AWS_SECRET_ACCESS_KEY=... +# AWS_STORAGE_BUCKET_NAME=ilo-media +# AWS_S3_REGION_NAME=... +# AWS_S3_ADDRESSING_STYLE=path # required for MinIO/ArvanCloud +DEFAULT_FILE_STORAGE=django.core.files.storage.FileSystemStorage + +# Payments. Console backend = auto-completes orders without hitting a real gateway (dev only). +# In production: PAYMENT_BACKEND=apps.payments.services.gateway.ZarinPalBackend +PAYMENT_BACKEND=apps.payments.services.gateway.ConsolePaymentBackend +PAYMENT_CALLBACK_BASE_URL=http://localhost:8000 +PAYMENT_SUCCESS_REDIRECT_URL=/payment/success +PAYMENT_FAILURE_REDIRECT_URL=/payment/failed + +# Platform commission % of every paid order (instructor gets the remainder). +ILO_COMMISSION_PERCENT=15 + +# ZarinPal — fill these and set ZARINPAL_SANDBOX=false for production. +ZARINPAL_MERCHANT_ID= +ZARINPAL_SANDBOX=true + +# IDPay — fill these to enable PAYMENT_BACKEND=apps.payments.services.gateway.IDPayBackend +IDPAY_API_KEY= +IDPAY_SANDBOX=true + +# Referral split (applied at checkout when a referral code is supplied) +REFERRAL_BUYER_DISCOUNT_PERCENT=10 +REFERRAL_REFERRER_CREDIT_PERCENT=10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff26f68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +.env +.env.local +*.sqlite3 +db.sqlite3 +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ +staticfiles/ +media/ +.DS_Store +.idea/ +.vscode/ +*.log +locale/*/LC_MESSAGES/*.mo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb57265 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gettext \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..60d9cc7 --- /dev/null +++ b/README.md @@ -0,0 +1,323 @@ +# 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. diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000..176f9e9 --- /dev/null +++ b/apps/accounts/admin.py @@ -0,0 +1,69 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import gettext_lazy as _ + +from .models import OTPCode, TeacherProfile, User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + ordering = ("-date_joined",) + list_display = ("phone_number", "full_name", "role", "is_active", "is_staff", "date_joined") + list_filter = ("role", "is_active", "is_staff") + search_fields = ("phone_number", "full_name", "email") + readonly_fields = ("last_login", "date_joined") + + fieldsets = ( + (None, {"fields": ("phone_number", "password")}), + (_("Personal info"), {"fields": ("full_name", "email", "language")}), + ( + _("Role & permissions"), + {"fields": ("role", "is_active", "is_staff", "is_superuser", "groups", "user_permissions")}, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("phone_number", "full_name", "role", "password1", "password2"), + }, + ), + ) + + +@admin.register(TeacherProfile) +class TeacherProfileAdmin(admin.ModelAdmin): + list_display = ("user", "headline", "is_verified", "is_approved", "approved_at", "approved_by") + list_filter = ("is_approved", "is_verified") + search_fields = ("user__phone_number", "user__full_name", "headline", "bio") + raw_id_fields = ("user", "approved_by") + actions = ["approve_selected"] + + @admin.action(description=_("Approve selected teacher profiles")) + def approve_selected(self, request, queryset): + for tp in queryset.filter(is_approved=False): + tp.approve(by_user=request.user) + + +@admin.register(OTPCode) +class OTPCodeAdmin(admin.ModelAdmin): + list_display = ("phone_number", "code_masked", "created_at", "expires_at", "is_used", "attempts") + list_filter = ("is_used",) + search_fields = ("phone_number",) + readonly_fields = ( + "phone_number", + "code", + "created_at", + "expires_at", + "attempts", + "is_used", + "consumed_at", + ) + + @admin.display(description=_("code")) + def code_masked(self, obj): + if not obj.code: + return "" + return obj.code[0] + "*" * (len(obj.code) - 1) diff --git a/apps/accounts/api/__init__.py b/apps/accounts/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/api/public.py b/apps/accounts/api/public.py new file mode 100644 index 0000000..81bd3df --- /dev/null +++ b/apps/accounts/api/public.py @@ -0,0 +1,144 @@ +"""Public-facing endpoints for instructor profiles (used by the catalog UI).""" + +from datetime import timedelta + +from django.conf import settings +from django.db.models import Count, IntegerField, OuterRef, Q, Subquery +from django.db.models.functions import Coalesce +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import serializers +from rest_framework.generics import ListAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts.models import Role, User +from apps.courses.api.queries import annotate_course_stats +from apps.courses.api.serializers import CourseListSerializer +from apps.courses.models import Course, CourseStatus +from apps.payments.models import Order, OrderStatus + + +def _instructor_qs(): + """Teachers (approved or not — all show on the catalog) annotated with stats.""" + return User.objects.filter(role=Role.TEACHER).select_related("teacher_profile") + + +def _profile(obj): + """Returns the user's TeacherProfile or None (defensive — should always exist for teachers).""" + return getattr(obj, "teacher_profile", None) + + +class InstructorListItemSerializer(serializers.Serializer): + id = serializers.IntegerField() + full_name = serializers.CharField() + headline = serializers.SerializerMethodField() + is_verified = serializers.SerializerMethodField() + avatar = serializers.SerializerMethodField() + courses_count = serializers.IntegerField(default=0) + students_count = serializers.IntegerField(default=0) + average_rating = serializers.FloatField(allow_null=True, default=None) + + def get_headline(self, obj): + tp = _profile(obj) + return tp.headline if tp else "" + + def get_is_verified(self, obj): + tp = _profile(obj) + return bool(tp.is_verified) if tp else False + + def get_avatar(self, obj): + tp = _profile(obj) + if not tp or not tp.avatar: + return None + request = self.context.get("request") + return request.build_absolute_uri(tp.avatar.url) if request else tp.avatar.url + + +class InstructorDetailSerializer(InstructorListItemSerializer): + bio = serializers.SerializerMethodField() + years_experience = serializers.SerializerMethodField() + + def get_bio(self, obj): + tp = _profile(obj) + return tp.bio if tp else "" + + def get_years_experience(self, obj): + tp = _profile(obj) + return tp.years_experience if tp else None + + +def _annotate_instructor_stats(qs): + """Adds courses_count and students_count subqueries.""" + courses_sq = ( + Course.objects.filter(instructor=OuterRef("pk"), status=CourseStatus.PUBLISHED) + .order_by() + .values("instructor") + .annotate(c=Count("id")) + .values("c")[:1] + ) + students_sq = ( + Order.objects.filter(course__instructor=OuterRef("pk"), status=OrderStatus.PAID) + .order_by() + .values("course__instructor") + .annotate(c=Count("student", distinct=True)) + .values("c")[:1] + ) + return qs.annotate( + courses_count=Coalesce(Subquery(courses_sq, output_field=IntegerField()), 0), + students_count=Coalesce(Subquery(students_sq, output_field=IntegerField()), 0), + ) + + +@extend_schema( + tags=["instructors-public"], + summary="List public instructors", + description=( + "Used by the catalog's 'featured instructors' strip. Each instructor includes their " + "published-courses count and a unique-paid-students count. Pass `?is_featured=true` " + "to scope to only the strip's curated list." + ), +) +class InstructorListView(ListAPIView): + permission_classes = [AllowAny] + pagination_class = None + serializer_class = InstructorListItemSerializer + ordering_fields = ["students_count", "courses_count", "id"] + ordering = ["-students_count", "-courses_count"] + + def get_queryset(self): + qs = _annotate_instructor_stats(_instructor_qs()) + if self.request.query_params.get("is_featured", "").lower() in {"1", "true", "yes"}: + qs = qs.filter(teacher_profile__is_featured=True) + return qs + + +@extend_schema( + tags=["instructors-public"], + summary="Public instructor profile", + description=( + "Returns the instructor's full public profile (bio, headline, avatar, verified, " + "years_experience) plus stats (`courses_count`, `students_count`) and the list of their " + "published courses with the standard catalog shape." + ), + responses={200: InstructorDetailSerializer, 404: OpenApiResponse(description="Not found.")}, +) +class InstructorDetailView(APIView): + permission_classes = [AllowAny] + + def get(self, request, instructor_id): + qs = _annotate_instructor_stats(_instructor_qs()) + instructor = get_object_or_404(qs, pk=instructor_id) + + courses = annotate_course_stats( + Course.objects.filter(instructor=instructor, status=CourseStatus.PUBLISHED) + .select_related("instructor", "category") + ).order_by("-published_at") + + body = InstructorDetailSerializer(instructor, context={"request": request}).data + body["courses"] = CourseListSerializer( + courses, many=True, context={"request": request} + ).data + return Response(body) diff --git a/apps/accounts/api/public_urls.py b/apps/accounts/api/public_urls.py new file mode 100644 index 0000000..300008c --- /dev/null +++ b/apps/accounts/api/public_urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from .public import InstructorDetailView, InstructorListView + +urlpatterns = [ + path("instructors/", InstructorListView.as_view(), name="public-instructors"), + path( + "instructors//", + InstructorDetailView.as_view(), + name="public-instructor-detail", + ), +] diff --git a/apps/accounts/api/serializers.py b/apps/accounts/api/serializers.py new file mode 100644 index 0000000..29b03fe --- /dev/null +++ b/apps/accounts/api/serializers.py @@ -0,0 +1,57 @@ +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework import serializers +from rest_framework_simplejwt.tokens import RefreshToken + +from ..models import Role +from ..validators import normalize_iran_mobile, validate_iran_mobile + +User = get_user_model() + + +class IranPhoneField(serializers.CharField): + def to_internal_value(self, data): + v = super().to_internal_value(data) + normalized = normalize_iran_mobile(v) + try: + validate_iran_mobile(normalized) + except DjangoValidationError: + raise serializers.ValidationError("Enter a valid Iranian mobile number.") + return normalized + + +class OTPSendSerializer(serializers.Serializer): + phone_number = IranPhoneField() + + +class OTPVerifySerializer(serializers.Serializer): + phone_number = IranPhoneField() + code = serializers.CharField(min_length=4, max_length=6) + full_name = serializers.CharField(required=False, allow_blank=True, max_length=150) + role = serializers.ChoiceField( + required=False, + choices=[(Role.STUDENT.value, "student"), (Role.TEACHER.value, "teacher")], + ) + + def validate(self, attrs): + if not User.objects.filter(phone_number=attrs["phone_number"]).exists(): + missing = {} + if not attrs.get("full_name"): + missing["full_name"] = "Required for new account" + if not attrs.get("role"): + missing["role"] = "Required for new account" + if missing: + raise serializers.ValidationError(missing) + return attrs + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "phone_number", "full_name", "role", "email", "language"] + read_only_fields = fields + + +def issue_tokens_for(user) -> dict: + refresh = RefreshToken.for_user(user) + return {"access": str(refresh.access_token), "refresh": str(refresh)} diff --git a/apps/accounts/api/throttling.py b/apps/accounts/api/throttling.py new file mode 100644 index 0000000..064f757 --- /dev/null +++ b/apps/accounts/api/throttling.py @@ -0,0 +1,23 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class OTPSendByPhoneThrottle(SimpleRateThrottle): + """Per-phone hourly limit on OTP sends (configured via DEFAULT_THROTTLE_RATES).""" + + scope = "otp_send_phone" + + def get_cache_key(self, request, view): + phone = (request.data or {}).get("phone_number") if hasattr(request, "data") else None + if not phone: + return None + return self.cache_format % {"scope": self.scope, "ident": phone} + + +class OTPSendByIPThrottle(SimpleRateThrottle): + """Per-IP throttle on OTP sends to absorb scripted abuse from a single client.""" + + scope = "otp_send_ip" + + def get_cache_key(self, request, view): + ident = self.get_ident(request) + return self.cache_format % {"scope": self.scope, "ident": ident} diff --git a/apps/accounts/api/urls.py b/apps/accounts/api/urls.py new file mode 100644 index 0000000..a842c3b --- /dev/null +++ b/apps/accounts/api/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import LogoutView, MeView, OTPSendView, OTPVerifyView + +app_name = "accounts_api" + +urlpatterns = [ + path("otp/send/", OTPSendView.as_view(), name="otp-send"), + path("otp/verify/", OTPVerifyView.as_view(), name="otp-verify"), + path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"), + path("logout/", LogoutView.as_view(), name="logout"), + path("me/", MeView.as_view(), name="me"), +] diff --git a/apps/accounts/api/views.py b/apps/accounts/api/views.py new file mode 100644 index 0000000..9c998e0 --- /dev/null +++ b/apps/accounts/api/views.py @@ -0,0 +1,234 @@ +from django.contrib.auth import get_user_model +from django.db import transaction +from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.tokens import RefreshToken + +from ..models import Role, TeacherProfile +from ..services.otp import ( + OTPCooldownError, + OTPVerificationError, + send_otp, + verify_otp, +) +from ..services.sms import SMSError +from .serializers import ( + OTPSendSerializer, + OTPVerifySerializer, + UserSerializer, + issue_tokens_for, +) +from .throttling import OTPSendByIPThrottle, OTPSendByPhoneThrottle + +User = get_user_model() + + +_TOKEN_PAIR = inline_serializer( + name="TokenPair", + fields={ + "access": drf_serializers.CharField(), + "refresh": drf_serializers.CharField(), + }, +) + + +class OTPSendView(APIView): + permission_classes = [AllowAny] + throttle_classes = [OTPSendByPhoneThrottle, OTPSendByIPThrottle] + + @extend_schema( + tags=["auth"], + summary="Send OTP", + description=( + "Generate a 4-digit OTP and dispatch it to the given Iranian phone number " + "via the configured SMS backend. Phone is normalized to `09xxxxxxxxx`. " + "Per-phone cooldown of 60 seconds; per-phone hourly cap of 5; per-IP cap of 30/min." + ), + request=OTPSendSerializer, + responses={ + 200: OpenApiResponse( + response=inline_serializer( + name="OTPSendResponse", + fields={ + "detail": drf_serializers.CharField(), + "phone_number": drf_serializers.CharField(), + }, + ), + description="OTP issued and SMS dispatched.", + ), + 400: OpenApiResponse(description="Phone number is missing or invalid."), + 429: OpenApiResponse( + response=inline_serializer( + name="OTPCooldownResponse", + fields={ + "detail": drf_serializers.CharField(), + "retry_after": drf_serializers.IntegerField(), + }, + ), + description="Cooldown active or hourly cap reached.", + ), + 502: OpenApiResponse(description="SMS provider is unreachable or returned an error."), + }, + examples=[ + OpenApiExample( + "Iran phone, canonical", + value={"phone_number": "09121234567"}, + request_only=True, + ), + OpenApiExample( + "Iran phone, E.164", + value={"phone_number": "+989121234567"}, + request_only=True, + ), + ], + ) + def post(self, request): + ser = OTPSendSerializer(data=request.data) + ser.is_valid(raise_exception=True) + phone = ser.validated_data["phone_number"] + + try: + send_otp(phone) + except OTPCooldownError as e: + return Response( + {"detail": "Too soon", "retry_after": e.retry_after}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + except SMSError as e: + return Response( + {"detail": "Could not send SMS", "error": str(e)}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + return Response({"detail": "Code sent", "phone_number": phone}) + + +class OTPVerifyView(APIView): + permission_classes = [AllowAny] + + @extend_schema( + tags=["auth"], + summary="Verify OTP and login or signup", + description=( + "Consumes the latest active OTP for the phone.\n\n" + "- **Existing user** → login. Body needs only `phone_number` and `code`.\n" + "- **New user** → signup. Body must also include `full_name` and `role` (`student` or `teacher`). " + "If `role=teacher`, a `TeacherProfile` is auto-created with `is_approved=False` " + "(an admin must approve before the teacher can publish courses).\n\n" + "Returns access + refresh JWTs on success." + ), + request=OTPVerifySerializer, + responses={ + 200: OpenApiResponse( + response=inline_serializer( + name="OTPVerifyResponse", + fields={ + "created": drf_serializers.BooleanField(), + "user": UserSerializer(), + "tokens": _TOKEN_PAIR, + }, + ), + description="Login or signup succeeded; tokens issued.", + ), + 400: OpenApiResponse( + description=( + "Possible reasons: invalid code, code expired, no active code for phone, " + "too many wrong attempts, or signup fields missing for new account." + ) + ), + }, + examples=[ + OpenApiExample( + "Existing user — login", + value={"phone_number": "09121234567", "code": "1234"}, + request_only=True, + ), + OpenApiExample( + "New user — signup as teacher", + value={ + "phone_number": "09121234567", + "code": "1234", + "full_name": "Ali Reza", + "role": "teacher", + }, + request_only=True, + ), + ], + ) + def post(self, request): + ser = OTPVerifySerializer(data=request.data) + ser.is_valid(raise_exception=True) + phone = ser.validated_data["phone_number"] + code = ser.validated_data["code"] + + try: + verify_otp(phone, code) + except OTPVerificationError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + user = User.objects.filter(phone_number=phone).first() + created = False + if user is None: + with transaction.atomic(): + user = User.objects.create_user( + phone_number=phone, + full_name=ser.validated_data["full_name"], + role=ser.validated_data["role"], + ) + if user.role == Role.TEACHER: + TeacherProfile.objects.create(user=user) + created = True + + tokens = issue_tokens_for(user) + return Response( + { + "created": created, + "user": UserSerializer(user).data, + "tokens": tokens, + } + ) + + +class MeView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["auth"], + summary="Current user", + description="Returns the authenticated user's profile (id, phone, full name, role, email, language).", + responses={200: UserSerializer, 401: OpenApiResponse(description="Missing or invalid token.")}, + ) + def get(self, request): + return Response(UserSerializer(request.user).data) + + +class LogoutView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["auth"], + summary="Logout (blacklist refresh token)", + description="Blacklists the supplied refresh token so it cannot be rotated again. The access token remains valid until natural expiry.", + request=inline_serializer( + name="LogoutRequest", + fields={"refresh": drf_serializers.CharField()}, + ), + responses={ + 204: OpenApiResponse(description="Refresh token blacklisted."), + 400: OpenApiResponse(description="Missing or invalid refresh token."), + }, + ) + def post(self, request): + refresh = request.data.get("refresh") + if not refresh: + return Response({"detail": "refresh required"}, status=status.HTTP_400_BAD_REQUEST) + try: + RefreshToken(refresh).blacklist() + except TokenError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..7d07530 --- /dev/null +++ b/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + label = "accounts" diff --git a/apps/accounts/auth.py b/apps/accounts/auth.py new file mode 100644 index 0000000..cb18159 --- /dev/null +++ b/apps/accounts/auth.py @@ -0,0 +1,26 @@ +"""Custom JWT authentication that touches `User.last_active_at` on each request.""" + +from datetime import timedelta + +from django.utils import timezone +from rest_framework_simplejwt.authentication import JWTAuthentication + + +class TrackingJWTAuthentication(JWTAuthentication): + """Wraps simplejwt's auth and bumps `last_active_at` whenever an authenticated + request comes in. Debounced — at most one DB write per user per `DEBOUNCE_SECONDS`. + """ + + DEBOUNCE_SECONDS = 5 * 60 # 5 minutes + + def authenticate(self, request): + result = super().authenticate(request) + if result is None: + return None + user, validated_token = result + now = timezone.now() + last = user.last_active_at + if last is None or (now - last).total_seconds() > self.DEBOUNCE_SECONDS: + type(user).objects.filter(pk=user.pk).update(last_active_at=now) + user.last_active_at = now + return user, validated_token diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..7cc685d --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 5.1.15 on 2026-05-02 06:48 + +import apps.accounts.models +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('phone_number', models.CharField(max_length=11, unique=True, verbose_name='phone number')), + ('full_name', models.CharField(max_length=150, verbose_name='full name')), + ('role', models.CharField(choices=[('student', 'Student'), ('teacher', 'Teacher'), ('admin', 'Admin')], default='student', max_length=16, verbose_name='role')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email')), + ('language', models.CharField(choices=[('en', 'English'), ('fa', 'Persian')], default='fa', max_length=5, verbose_name='language')), + ('is_active', models.BooleanField(default=True, verbose_name='active')), + ('is_staff', models.BooleanField(default=False, verbose_name='staff status')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + }, + managers=[ + ('objects', apps.accounts.models.UserManager()), + ], + ), + migrations.CreateModel( + name='OTPCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone_number', models.CharField(db_index=True, max_length=11)), + ('code', models.CharField(max_length=6)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField()), + ('attempts', models.PositiveIntegerField(default=0)), + ('is_used', models.BooleanField(default=False)), + ('consumed_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['phone_number', 'is_used'], name='accounts_ot_phone_n_aabe5e_idx'), models.Index(fields=['expires_at'], name='accounts_ot_expires_2f08f4_idx')], + }, + ), + migrations.CreateModel( + name='TeacherProfile', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='teacher_profile', serialize=False, to=settings.AUTH_USER_MODEL)), + ('bio', models.TextField(blank=True, verbose_name='bio')), + ('is_approved', models.BooleanField(default=False, verbose_name='approved')), + ('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='approved at')), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_teacher_profiles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'teacher profile', + 'verbose_name_plural': 'teacher profiles', + }, + ), + migrations.AddIndex( + model_name='user', + index=models.Index(fields=['role'], name='accounts_us_role_1fa9a5_idx'), + ), + ] diff --git a/apps/accounts/migrations/0002_teacherprofile_avatar_teacherprofile_headline_and_more.py b/apps/accounts/migrations/0002_teacherprofile_avatar_teacherprofile_headline_and_more.py new file mode 100644 index 0000000..f1fd4ce --- /dev/null +++ b/apps/accounts/migrations/0002_teacherprofile_avatar_teacherprofile_headline_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.15 on 2026-05-02 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='teacherprofile', + name='avatar', + field=models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='avatar'), + ), + migrations.AddField( + model_name='teacherprofile', + name='headline', + field=models.CharField(blank=True, help_text="Short professional headline shown on instructor cards (e.g. 'Senior Product Designer · 10y experience').", max_length=200, verbose_name='headline'), + ), + migrations.AddField( + model_name='teacherprofile', + name='is_verified', + field=models.BooleanField(default=False, verbose_name='verified'), + ), + migrations.AddField( + model_name='teacherprofile', + name='years_experience', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='years of experience'), + ), + ] diff --git a/apps/accounts/migrations/0003_user_last_active_at.py b/apps/accounts/migrations/0003_user_last_active_at.py new file mode 100644 index 0000000..b0e1798 --- /dev/null +++ b/apps/accounts/migrations/0003_user_last_active_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.15 on 2026-05-02 16:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_teacherprofile_avatar_teacherprofile_headline_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='last_active_at', + field=models.DateTimeField(blank=True, db_index=True, help_text='Updated on each authenticated API request (debounced ~5 minutes).', null=True, verbose_name='last active at'), + ), + ] diff --git a/apps/accounts/migrations/0004_teacherprofile_is_featured_user_referral_code.py b/apps/accounts/migrations/0004_teacherprofile_is_featured_user_referral_code.py new file mode 100644 index 0000000..9726acb --- /dev/null +++ b/apps/accounts/migrations/0004_teacherprofile_is_featured_user_referral_code.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.15 on 2026-05-02 16:12 — hand-edited to backfill referral_code + +import secrets +import string + +from django.db import migrations, models + + +def _backfill_referral_codes(apps, schema_editor): + User = apps.get_model("accounts", "User") + alphabet = string.ascii_uppercase + string.digits + existing = set(User.objects.exclude(referral_code="").values_list("referral_code", flat=True)) + for user in User.objects.filter(referral_code=""): + for _ in range(20): + cand = "".join(secrets.choice(alphabet) for _ in range(6)) + if cand not in existing: + existing.add(cand) + user.referral_code = cand + user.save(update_fields=["referral_code"]) + break + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_user_last_active_at'), + ] + + operations = [ + migrations.AddField( + model_name='teacherprofile', + name='is_featured', + field=models.BooleanField(db_index=True, default=False, help_text="If true, the instructor appears in the public 'Featured instructors' strip.", verbose_name='featured'), + ), + # Step 1: add the column without unique constraint OR db_index so the default + # empty string doesn't collide and so we don't pre-create the `_like` index + # that AlterField would try to add a second time below. + migrations.AddField( + model_name='user', + name='referral_code', + field=models.CharField(blank=True, default='', help_text='Auto-generated 6-char code that other users can apply at checkout.', max_length=12, verbose_name='referral code'), + ), + # Step 2: backfill unique codes for any existing rows. + migrations.RunPython(_backfill_referral_codes, migrations.RunPython.noop), + # Step 3: now safe to add the unique constraint. + migrations.AlterField( + model_name='user', + name='referral_code', + field=models.CharField(blank=True, db_index=True, help_text='Auto-generated 6-char code that other users can apply at checkout.', max_length=12, unique=True, verbose_name='referral code'), + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..ac64ec9 --- /dev/null +++ b/apps/accounts/models.py @@ -0,0 +1,208 @@ +import secrets +import string +from datetime import timedelta + +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from .validators import normalize_iran_mobile, validate_iran_mobile + + +_REFERRAL_ALPHABET = string.ascii_uppercase + string.digits + + +def generate_referral_code(length: int = 6) -> str: + return "".join(secrets.choice(_REFERRAL_ALPHABET) for _ in range(length)) + + +class Role(models.TextChoices): + STUDENT = "student", _("Student") + TEACHER = "teacher", _("Teacher") + ADMIN = "admin", _("Admin") + + +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, phone_number, password=None, **extra_fields): + if not phone_number: + raise ValueError("Phone number is required") + phone_number = normalize_iran_mobile(phone_number) + validate_iran_mobile(phone_number) + user = self.model(phone_number=phone_number, **extra_fields) + if password: + user.set_password(password) + else: + user.set_unusable_password() + user.save(using=self._db) + return user + + def create_user(self, phone_number, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(phone_number, password, **extra_fields) + + def create_superuser(self, phone_number, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("role", Role.ADMIN) + extra_fields.setdefault("full_name", "Admin") + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + return self._create_user(phone_number, password, **extra_fields) + + +class User(AbstractBaseUser, PermissionsMixin): + phone_number = models.CharField(_("phone number"), max_length=11, unique=True) + full_name = models.CharField(_("full name"), max_length=150) + role = models.CharField(_("role"), max_length=16, choices=Role.choices, default=Role.STUDENT) + email = models.EmailField(_("email"), blank=True) + language = models.CharField( + _("language"), + max_length=5, + choices=[("en", "English"), ("fa", "Persian")], + default="fa", + ) + + is_active = models.BooleanField(_("active"), default=True) + is_staff = models.BooleanField(_("staff status"), default=False) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + last_active_at = models.DateTimeField( + _("last active at"), null=True, blank=True, db_index=True, + help_text=_("Updated on each authenticated API request (debounced ~5 minutes)."), + ) + referral_code = models.CharField( + _("referral code"), max_length=12, unique=True, blank=True, db_index=True, + help_text=_("Auto-generated 6-char code that other users can apply at checkout."), + ) + + objects = UserManager() + + USERNAME_FIELD = "phone_number" + REQUIRED_FIELDS = ["full_name"] + + class Meta: + verbose_name = _("user") + verbose_name_plural = _("users") + indexes = [models.Index(fields=["role"])] + + def __str__(self): + return f"{self.full_name} ({self.phone_number})" + + def clean(self): + super().clean() + self.phone_number = normalize_iran_mobile(self.phone_number) + validate_iran_mobile(self.phone_number) + + def save(self, *args, **kwargs): + if not self.referral_code: + for _ in range(10): + candidate = generate_referral_code() + if not type(self).objects.filter(referral_code=candidate).exists(): + self.referral_code = candidate + break + else: + # extremely unlikely; expand the alphabet/length if it ever happens + raise RuntimeError("Could not allocate a unique referral_code after 10 attempts") + super().save(*args, **kwargs) + + def get_short_name(self): + return (self.full_name or "").split(" ", 1)[0] or self.phone_number + + +class TeacherProfile(models.Model): + user = models.OneToOneField( + "accounts.User", + on_delete=models.CASCADE, + related_name="teacher_profile", + primary_key=True, + ) + bio = models.TextField(_("bio"), blank=True) + headline = models.CharField( + _("headline"), max_length=200, blank=True, + help_text=_("Short professional headline shown on instructor cards (e.g. 'Senior Product Designer · 10y experience')."), + ) + avatar = models.ImageField(_("avatar"), upload_to="instructors/avatars/", blank=True, null=True) + years_experience = models.PositiveSmallIntegerField(_("years of experience"), null=True, blank=True) + is_verified = models.BooleanField(_("verified"), default=False) + is_featured = models.BooleanField( + _("featured"), default=False, db_index=True, + help_text=_("If true, the instructor appears in the public 'Featured instructors' strip."), + ) + is_approved = models.BooleanField(_("approved"), default=False) + approved_at = models.DateTimeField(_("approved at"), null=True, blank=True) + approved_by = models.ForeignKey( + "accounts.User", + on_delete=models.SET_NULL, + related_name="approved_teacher_profiles", + null=True, + blank=True, + ) + + class Meta: + verbose_name = _("teacher profile") + verbose_name_plural = _("teacher profiles") + + def __str__(self): + return f"TeacherProfile<{self.user_id}>" + + def approve(self, by_user): + self.is_approved = True + self.approved_at = timezone.now() + self.approved_by = by_user + self.save(update_fields=["is_approved", "approved_at", "approved_by"]) + + +def _generate_otp_code(length: int = 4) -> str: + upper = 10 ** length + return f"{secrets.randbelow(upper):0{length}d}" + + +class OTPCode(models.Model): + MAX_ATTEMPTS = 5 + + phone_number = models.CharField(max_length=11, db_index=True) + code = models.CharField(max_length=6) + created_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + attempts = models.PositiveIntegerField(default=0) + is_used = models.BooleanField(default=False) + consumed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["phone_number", "is_used"]), + models.Index(fields=["expires_at"]), + ] + ordering = ["-created_at"] + + def __str__(self): + return f"OTP<{self.phone_number} @ {self.created_at:%Y-%m-%d %H:%M:%S}>" + + @classmethod + def issue(cls, phone_number: str, ttl_seconds: int = 120, length: int = 4) -> "OTPCode": + cls.objects.filter(phone_number=phone_number, is_used=False).update( + is_used=True, consumed_at=timezone.now() + ) + return cls.objects.create( + phone_number=phone_number, + code=_generate_otp_code(length=length), + expires_at=timezone.now() + timedelta(seconds=ttl_seconds), + ) + + @property + def is_expired(self) -> bool: + return timezone.now() >= self.expires_at + + @property + def is_valid(self) -> bool: + return not self.is_used and not self.is_expired and self.attempts < self.MAX_ATTEMPTS + + def mark_consumed(self): + self.is_used = True + self.consumed_at = timezone.now() + self.save(update_fields=["is_used", "consumed_at"]) diff --git a/apps/accounts/services/__init__.py b/apps/accounts/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/services/otp.py b/apps/accounts/services/otp.py new file mode 100644 index 0000000..db84a6f --- /dev/null +++ b/apps/accounts/services/otp.py @@ -0,0 +1,61 @@ +from django.conf import settings +from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ + +from ..models import OTPCode +from .sms import SMSError, get_sms_backend + + +class OTPCooldownError(Exception): + def __init__(self, retry_after: int): + self.retry_after = retry_after + super().__init__(f"Wait {retry_after} seconds before requesting another code") + + +class OTPVerificationError(Exception): + pass + + +def _cooldown_key(phone: str) -> str: + return f"otp:cooldown:{phone}" + + +def send_otp(phone_number: str) -> OTPCode: + cooldown = getattr(settings, "OTP_SEND_COOLDOWN_SECONDS", 60) + if cache.get(_cooldown_key(phone_number)): + raise OTPCooldownError(retry_after=cooldown) + + ttl_seconds = getattr(settings, "OTP_TTL_SECONDS", 120) + length = getattr(settings, "OTP_CODE_LENGTH", 4) + + otp = OTPCode.issue(phone_number=phone_number, ttl_seconds=ttl_seconds, length=length) + + try: + get_sms_backend().send_otp(phone_number, otp.code) + except SMSError: + otp.delete() + raise + + cache.set(_cooldown_key(phone_number), "1", timeout=cooldown) + return otp + + +def verify_otp(phone_number: str, code: str) -> OTPCode: + otp = ( + OTPCode.objects.filter(phone_number=phone_number, is_used=False) + .order_by("-created_at") + .first() + ) + if otp is None: + raise OTPVerificationError(_("No active code for this phone number")) + if otp.is_expired: + raise OTPVerificationError(_("Code has expired")) + if otp.attempts >= OTPCode.MAX_ATTEMPTS: + raise OTPVerificationError(_("Too many attempts; request a new code")) + + if otp.code != str(code): + OTPCode.objects.filter(pk=otp.pk).update(attempts=otp.attempts + 1) + raise OTPVerificationError(_("Invalid code")) + + otp.mark_consumed() + return otp diff --git a/apps/accounts/services/sms.py b/apps/accounts/services/sms.py new file mode 100644 index 0000000..a03ea91 --- /dev/null +++ b/apps/accounts/services/sms.py @@ -0,0 +1,70 @@ +import logging +from typing import Protocol + +import requests +from django.conf import settings +from django.utils.module_loading import import_string + +logger = logging.getLogger(__name__) + + +class SMSError(Exception): + pass + + +class SMSBackend(Protocol): + def send_otp(self, phone_number: str, code: str) -> None: ... + + +class ConsoleSMSBackend: + """Dev backend — logs the OTP instead of sending. Never use in production.""" + + def send_otp(self, phone_number: str, code: str) -> None: + logger.warning("[SMS-CONSOLE] OTP for %s: %s", phone_number, code) + + +class SMSIRBackend: + """sms.ir template-based OTP via /v1/send/verify.""" + + def __init__(self): + self.api_key = settings.SMSIR_API_KEY + self.template_id = settings.SMSIR_TEMPLATE_ID + self.param_name = settings.SMSIR_TEMPLATE_PARAM_NAME + self.base_url = settings.SMSIR_BASE_URL.rstrip("/") + self.timeout = 15 + if not self.api_key: + raise SMSError("SMSIR_API_KEY is not configured") + if not self.template_id: + raise SMSError("SMSIR_TEMPLATE_ID is not configured") + + def send_otp(self, phone_number: str, code: str) -> None: + url = f"{self.base_url}/v1/send/verify" + headers = {"x-api-key": self.api_key, "Content-Type": "application/json"} + payload = { + "mobile": phone_number, + "templateId": int(self.template_id), + "parameters": [{"name": self.param_name, "value": str(code)}], + } + try: + resp = requests.post(url, json=payload, headers=headers, timeout=self.timeout) + except requests.RequestException as e: + raise SMSError(f"SMS network error: {e}") from e + + try: + data = resp.json() + except ValueError as e: + raise SMSError(f"SMS non-JSON response (HTTP {resp.status_code})") from e + + if not (200 <= resp.status_code < 300) or data.get("status") != 1: + msg = data.get("message") or f"HTTP {resp.status_code}" + raise SMSError(f"sms.ir error: {msg}") + + logger.info("OTP delivered to %s via sms.ir template %s", phone_number, self.template_id) + + +_DEFAULT_BACKEND = "apps.accounts.services.sms.ConsoleSMSBackend" + + +def get_sms_backend() -> SMSBackend: + backend_path = getattr(settings, "SMS_BACKEND", _DEFAULT_BACKEND) + return import_string(backend_path)() diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 0000000..2f39e0f --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,237 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from apps.accounts.models import OTPCode, Role, TeacherProfile +from apps.accounts.services.otp import ( + OTPCooldownError, + OTPVerificationError, + send_otp, + verify_otp, +) +from apps.accounts.validators import normalize_iran_mobile, validate_iran_mobile + +User = get_user_model() + +CONSOLE_SMS = "apps.accounts.services.sms.ConsoleSMSBackend" + + +class PhoneNormalizationTests(TestCase): + def test_normalizes_e164(self): + self.assertEqual(normalize_iran_mobile("+989121234567"), "09121234567") + + def test_normalizes_0098_prefix(self): + self.assertEqual(normalize_iran_mobile("00989121234567"), "09121234567") + + def test_normalizes_98_prefix(self): + self.assertEqual(normalize_iran_mobile("989121234567"), "09121234567") + + def test_normalizes_no_leading_zero(self): + self.assertEqual(normalize_iran_mobile("9121234567"), "09121234567") + + def test_keeps_canonical_form(self): + self.assertEqual(normalize_iran_mobile("09121234567"), "09121234567") + + def test_strips_separators(self): + self.assertEqual(normalize_iran_mobile("0912 123 4567"), "09121234567") + + def test_validates_canonical(self): + validate_iran_mobile("09121234567") + + def test_rejects_too_short(self): + with self.assertRaises(ValidationError): + validate_iran_mobile("0912123456") + + def test_rejects_non_iran(self): + with self.assertRaises(ValidationError): + validate_iran_mobile("+12025551234") + + +@override_settings(SMS_BACKEND=CONSOLE_SMS) +class OTPServiceTests(TestCase): + def setUp(self): + cache.clear() + + def test_send_otp_creates_code(self): + otp = send_otp("09121234567") + self.assertEqual(otp.phone_number, "09121234567") + self.assertEqual(len(otp.code), 4) + self.assertFalse(otp.is_used) + + def test_send_otp_within_cooldown_raises(self): + send_otp("09121234567") + with self.assertRaises(OTPCooldownError): + send_otp("09121234567") + + def test_verify_correct_code_consumes(self): + otp = send_otp("09121234567") + verified = verify_otp("09121234567", otp.code) + self.assertEqual(verified.pk, otp.pk) + otp.refresh_from_db() + self.assertTrue(otp.is_used) + + def test_verify_wrong_code_increments_attempts(self): + send_otp("09121234567") + with self.assertRaises(OTPVerificationError): + verify_otp("09121234567", "0000") + latest = OTPCode.objects.filter(phone_number="09121234567").first() + self.assertEqual(latest.attempts, 1) + self.assertFalse(latest.is_used) + + def test_verify_with_no_active_code_raises(self): + with self.assertRaises(OTPVerificationError): + verify_otp("09121234567", "1234") + + def test_issuing_new_otp_invalidates_previous(self): + first = send_otp("09121234567") + cache.clear() + second = send_otp("09121234567") + first.refresh_from_db() + self.assertTrue(first.is_used) + self.assertFalse(second.is_used) + self.assertNotEqual(first.pk, second.pk) + + +@override_settings(SMS_BACKEND=CONSOLE_SMS) +class AuthAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + + def _send(self, phone): + return self.client.post("/api/auth/otp/send/", {"phone_number": phone}, format="json") + + def _latest_otp(self, phone): + return OTPCode.objects.get(phone_number=phone, is_used=False) + + def test_signup_flow_creates_teacher_with_profile(self): + r = self._send("09121234567") + self.assertEqual(r.status_code, 200, r.content) + otp = self._latest_otp("09121234567") + + r = self.client.post( + "/api/auth/otp/verify/", + { + "phone_number": "09121234567", + "code": otp.code, + "full_name": "New Teacher", + "role": "teacher", + }, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertTrue(body["created"]) + self.assertEqual(body["user"]["role"], "teacher") + self.assertIn("access", body["tokens"]) + self.assertIn("refresh", body["tokens"]) + + u = User.objects.get(phone_number="09121234567") + self.assertTrue(TeacherProfile.objects.filter(user=u).exists()) + self.assertFalse(u.teacher_profile.is_approved) + + def test_signup_flow_creates_student_no_profile(self): + self._send("09121234567") + otp = self._latest_otp("09121234567") + r = self.client.post( + "/api/auth/otp/verify/", + { + "phone_number": "09121234567", + "code": otp.code, + "full_name": "New Student", + "role": "student", + }, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + u = User.objects.get(phone_number="09121234567") + self.assertEqual(u.role, Role.STUDENT) + self.assertFalse(TeacherProfile.objects.filter(user=u).exists()) + + def test_existing_user_login_does_not_require_signup_fields(self): + u = User.objects.create_user( + phone_number="09121234567", full_name="Existing", role=Role.STUDENT + ) + self._send("09121234567") + otp = self._latest_otp("09121234567") + r = self.client.post( + "/api/auth/otp/verify/", + {"phone_number": "09121234567", "code": otp.code}, + format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertFalse(body["created"]) + self.assertEqual(body["user"]["id"], u.id) + + def test_new_user_without_signup_fields_rejected(self): + self._send("09121234567") + otp = self._latest_otp("09121234567") + r = self.client.post( + "/api/auth/otp/verify/", + {"phone_number": "09121234567", "code": otp.code}, + format="json", + ) + self.assertEqual(r.status_code, 400) + body = r.json() + self.assertIn("full_name", body) + self.assertIn("role", body) + + def test_verify_wrong_code_400(self): + self._send("09121234567") + r = self.client.post( + "/api/auth/otp/verify/", + { + "phone_number": "09121234567", + "code": "0000", + "full_name": "X", + "role": "student", + }, + format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_phone_normalization_in_send(self): + r = self.client.post( + "/api/auth/otp/send/", + {"phone_number": "+989121234567"}, + format="json", + ) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()["phone_number"], "09121234567") + self.assertTrue(OTPCode.objects.filter(phone_number="09121234567").exists()) + + def test_invalid_phone_400(self): + r = self.client.post( + "/api/auth/otp/send/", {"phone_number": "12345"}, format="json" + ) + self.assertEqual(r.status_code, 400) + + def test_cooldown_429(self): + self._send("09121234567") + r = self._send("09121234567") + self.assertEqual(r.status_code, 429) + self.assertIn("retry_after", r.json()) + + def test_me_requires_auth(self): + r = self.client.get("/api/auth/me/") + self.assertEqual(r.status_code, 401) + + def test_me_with_bearer_token(self): + User.objects.create_user( + phone_number="09121234567", full_name="X", role=Role.STUDENT + ) + self._send("09121234567") + otp = self._latest_otp("09121234567") + r = self.client.post( + "/api/auth/otp/verify/", + {"phone_number": "09121234567", "code": otp.code}, + format="json", + ) + access = r.json()["tokens"]["access"] + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") + r2 = self.client.get("/api/auth/me/") + self.assertEqual(r2.status_code, 200) + self.assertEqual(r2.json()["phone_number"], "09121234567") diff --git a/apps/accounts/tests_public.py b/apps/accounts/tests_public.py new file mode 100644 index 0000000..d697f87 --- /dev/null +++ b/apps/accounts/tests_public.py @@ -0,0 +1,77 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from apps.accounts.models import Role, TeacherProfile +from apps.courses.models import Course, CourseStatus +from apps.payments.models import Gateway, Order, OrderStatus + +User = get_user_model() + + +def _teacher(phone, name="T", approved=True): + u = User.objects.create_user(phone_number=phone, full_name=name, role=Role.TEACHER) + TeacherProfile.objects.create(user=u, is_approved=approved, bio=f"bio of {name}", headline=f"{name} headline") + return u + + +def _student(phone="09120000099"): + return User.objects.create_user(phone_number=phone, full_name="Student", role=Role.STUDENT) + + +def _course(instructor, title="C", published=True, price_toman=None): + c = Course.objects.create( + instructor=instructor, title=title, tagline="T", description="D", + level="beginner", language="fa", price_toman=price_toman, + ) + if published: + c.publish() + return c + + +class InstructorPublicEndpointsTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t1 = _teacher("09120000001", name="Arash") + self.t2 = _teacher("09120000002", name="Neda") + # t1: 2 courses, t2: 0 courses + _course(self.t1, title="C1") + _course(self.t1, title="C2") + + def test_list_instructors_returns_all_teachers_with_counts(self): + r = self.client.get("/api/instructors/") + self.assertEqual(r.status_code, 200) + body = r.json() + ids = {row["id"]: row for row in body} + self.assertIn(self.t1.id, ids) + self.assertIn(self.t2.id, ids) + self.assertEqual(ids[self.t1.id]["courses_count"], 2) + self.assertEqual(ids[self.t1.id]["students_count"], 0) + self.assertEqual(ids[self.t2.id]["courses_count"], 0) + + def test_instructor_detail_returns_bio_courses_stats(self): + r = self.client.get(f"/api/instructors/{self.t1.id}/") + self.assertEqual(r.status_code, 200) + body = r.json() + self.assertEqual(body["id"], self.t1.id) + self.assertEqual(body["full_name"], "Arash") + self.assertEqual(body["headline"], "Arash headline") + self.assertEqual(body["bio"], "bio of Arash") + self.assertEqual(body["courses_count"], 2) + self.assertEqual(len(body["courses"]), 2) + + def test_instructor_detail_404_for_non_teacher(self): + s = _student() + r = self.client.get(f"/api/instructors/{s.id}/") + self.assertEqual(r.status_code, 404) + + def test_students_count_increments_with_paid_orders(self): + course = _course(self.t1, title="Sold", price_toman=1000) + s1 = _student("09120000091") + s2 = _student("09120000092") + Order.objects.create(student=s1, course=course, amount_toman=1000, gateway=Gateway.CONSOLE, status=OrderStatus.PAID) + Order.objects.create(student=s2, course=course, amount_toman=1000, gateway=Gateway.CONSOLE, status=OrderStatus.PAID) + # Same student buys again — should still count as 1 unique student + Order.objects.create(student=s1, course=course, amount_toman=1000, gateway=Gateway.CONSOLE, status=OrderStatus.PAID) + r = self.client.get(f"/api/instructors/{self.t1.id}/") + self.assertEqual(r.json()["students_count"], 2) diff --git a/apps/accounts/validators.py b/apps/accounts/validators.py new file mode 100644 index 0000000..b369c5b --- /dev/null +++ b/apps/accounts/validators.py @@ -0,0 +1,27 @@ +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +_IRAN_MOBILE_RE = re.compile(r"^09\d{9}$") + + +def normalize_iran_mobile(value: str) -> str: + if value is None: + return "" + digits = re.sub(r"\D", "", str(value)) + if not digits: + return "" + if digits.startswith("0098"): + digits = "0" + digits[4:] + elif digits.startswith("98") and len(digits) == 12: + digits = "0" + digits[2:] + elif digits.startswith("9") and len(digits) == 10: + digits = "0" + digits + return digits + + +def validate_iran_mobile(value: str) -> None: + if not _IRAN_MOBILE_RE.match(value or ""): + raise ValidationError(_("Enter a valid Iranian mobile number (09xxxxxxxxx).")) diff --git a/apps/announcements/__init__.py b/apps/announcements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/announcements/admin.py b/apps/announcements/admin.py new file mode 100644 index 0000000..59dfd0c --- /dev/null +++ b/apps/announcements/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from modeltranslation.admin import TabbedTranslationAdmin + +from .models import Announcement + + +@admin.register(Announcement) +class AnnouncementAdmin(TabbedTranslationAdmin): + list_display = ("course", "posted_by", "created_at") + list_filter = ("course",) + search_fields = ("body", "course__slug") + raw_id_fields = ("course", "posted_by") + readonly_fields = ("created_at", "updated_at") diff --git a/apps/announcements/api/__init__.py b/apps/announcements/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/announcements/api/serializers.py b/apps/announcements/api/serializers.py new file mode 100644 index 0000000..077c9d6 --- /dev/null +++ b/apps/announcements/api/serializers.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from apps.announcements.models import Announcement +from apps.courses.models import Course + + +class AnnouncementSerializer(serializers.ModelSerializer): + posted_by_name = serializers.CharField(source="posted_by.full_name", read_only=True, allow_null=True) + + class Meta: + model = Announcement + fields = ["id", "course", "body", "posted_by", "posted_by_name", "created_at", "updated_at"] + read_only_fields = ["id", "posted_by", "posted_by_name", "created_at", "updated_at"] + + +class AnnouncementWriteSerializer(serializers.ModelSerializer): + course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all()) + + class Meta: + model = Announcement + fields = ["id", "course", "body"] + read_only_fields = ["id"] diff --git a/apps/announcements/api/urls.py b/apps/announcements/api/urls.py new file mode 100644 index 0000000..33e2c4f --- /dev/null +++ b/apps/announcements/api/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import InstructorAnnouncementViewSet, MyCourseAnnouncementsView + +instructor_router = DefaultRouter() +instructor_router.register( + r"announcements", InstructorAnnouncementViewSet, basename="instructor-announcement" +) + +urlpatterns = [ + path("instructor/", include((instructor_router.urls, "instructor-announcements"))), + path("me/courses//announcements/", MyCourseAnnouncementsView.as_view(), name="my-course-announcements"), +] diff --git a/apps/announcements/api/views.py b/apps/announcements/api/views.py new file mode 100644 index 0000000..8b50119 --- /dev/null +++ b/apps/announcements/api/views.py @@ -0,0 +1,84 @@ +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import ListAPIView +from rest_framework.permissions import IsAuthenticated + +from apps.announcements.models import Announcement +from apps.courses.api.permissions import IsCourseOwner, IsTeacher +from apps.courses.models import Course, CourseStatus +from apps.enrollments.api.permissions import IsStudent +from apps.enrollments.models import Enrollment + +from .serializers import AnnouncementSerializer, AnnouncementWriteSerializer + + +@extend_schema(tags=["announcements"]) +class InstructorAnnouncementViewSet(viewsets.ModelViewSet): + """Instructor CRUD over announcements on their own courses.""" + + permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner] + queryset = Announcement.objects.all().select_related("course", "posted_by") + filterset_fields = ["course"] + ordering = ["-created_at"] + + def get_queryset(self): + return super().get_queryset().filter(course__instructor=self.request.user) + + def get_serializer_class(self): + if self.action in {"create", "update", "partial_update"}: + return AnnouncementWriteSerializer + return AnnouncementSerializer + + def perform_create(self, serializer): + course = serializer.validated_data["course"] + if course.instructor_id != self.request.user.id: + raise PermissionDenied("You don't own this course.") + announcement = serializer.save(posted_by=self.request.user) + # Record activity event for the instructor's home feed + from apps.enrollments.models import ActivityKind + from apps.enrollments.services.events import record_event + + record_event( + course=course, + actor=self.request.user, + kind=ActivityKind.ANNOUNCEMENT_POSTED.value, + payload={ + "announcement_id": announcement.pk, + "preview": (announcement.body or "")[:120], + }, + ) + + +class MyCourseAnnouncementsView(ListAPIView): + """Student-facing list: announcements for a specific course the student is enrolled in.""" + + permission_classes = [IsAuthenticated, IsStudent] + serializer_class = AnnouncementSerializer + pagination_class = None + + @extend_schema( + tags=["announcements"], + summary="My course announcements", + description="Lists all announcements posted by the instructor on a course the student is enrolled in.", + responses={ + 200: AnnouncementSerializer(many=True), + 403: OpenApiResponse(description="Not enrolled."), + 404: OpenApiResponse(description="Course not found or not published."), + }, + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + slug = self.kwargs["slug"] + course = get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED) + if not Enrollment.objects.filter(student=self.request.user, course=course).exists(): + raise PermissionDenied("You are not enrolled in this course.") + return ( + Announcement.objects + .filter(course=course) + .select_related("posted_by") + .order_by("-created_at") + ) diff --git a/apps/announcements/apps.py b/apps/announcements/apps.py new file mode 100644 index 0000000..89b15eb --- /dev/null +++ b/apps/announcements/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AnnouncementsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.announcements" + label = "announcements" diff --git a/apps/announcements/migrations/0001_initial.py b/apps/announcements/migrations/0001_initial.py new file mode 100644 index 0000000..8d20661 --- /dev/null +++ b/apps/announcements/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.15 on 2026-05-02 15:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('courses', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Announcement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('body', models.TextField(verbose_name='body')), + ('body_en', models.TextField(null=True, verbose_name='body')), + ('body_fa', models.TextField(null=True, verbose_name='body')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcements', to='courses.course')), + ('posted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posted_announcements', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'announcement', + 'verbose_name_plural': 'announcements', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['course', '-created_at'], name='announcemen_course__b2aa6b_idx')], + }, + ), + ] diff --git a/apps/announcements/migrations/__init__.py b/apps/announcements/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/announcements/models.py b/apps/announcements/models.py new file mode 100644 index 0000000..1f1070a --- /dev/null +++ b/apps/announcements/models.py @@ -0,0 +1,30 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Announcement(models.Model): + course = models.ForeignKey( + "courses.Course", + on_delete=models.CASCADE, + related_name="announcements", + ) + body = models.TextField(_("body")) + posted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="posted_announcements", + ) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(_("updated at"), auto_now=True) + + class Meta: + verbose_name = _("announcement") + verbose_name_plural = _("announcements") + ordering = ["-created_at"] + indexes = [models.Index(fields=["course", "-created_at"])] + + def __str__(self): + return self.body[:60] diff --git a/apps/announcements/translation.py b/apps/announcements/translation.py new file mode 100644 index 0000000..0bb88f9 --- /dev/null +++ b/apps/announcements/translation.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from .models import Announcement + + +@register(Announcement) +class AnnouncementTranslationOptions(TranslationOptions): + fields = ("body",) diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..68868e8 --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" + label = "core" diff --git a/apps/core/search.py b/apps/core/search.py new file mode 100644 index 0000000..a73e41f --- /dev/null +++ b/apps/core/search.py @@ -0,0 +1,111 @@ +"""Unified `/api/search/` across courses + instructors (public). + +If the requester is an authenticated student, also returns matching lessons in +courses they're enrolled in. Designed for the topbar's command palette / search box. +""" + +from django.db.models import Q +from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer +from rest_framework import serializers +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts.api.public import ( + InstructorListItemSerializer, + _annotate_instructor_stats, + _instructor_qs, +) +from apps.accounts.models import Role +from apps.courses.api.queries import annotate_course_stats +from apps.courses.api.serializers import CourseListSerializer +from apps.courses.models import Course, CourseStatus, Lesson, LessonStatus +from apps.enrollments.models import Enrollment + + +class _LessonResultSerializer(serializers.ModelSerializer): + course_id = serializers.IntegerField(source="section.course.id", read_only=True) + course_slug = serializers.CharField(source="section.course.slug", read_only=True) + course_title = serializers.CharField(source="section.course.title", read_only=True) + + class Meta: + model = Lesson + fields = ["id", "title", "kind", "course_id", "course_slug", "course_title"] + + +class GlobalSearchView(APIView): + """`GET /api/search/?q=...&types=courses,instructors,lessons&limit=5`.""" + + permission_classes = [AllowAny] + + @extend_schema( + tags=["search"], + summary="Global search across courses + instructors (+ lessons for enrolled students)", + description=( + "Returns mixed results grouped by entity. `?types=` is a comma-separated whitelist; " + "default is `courses,instructors`. Lessons are only searched for authenticated students " + "and only within courses they're enrolled in. `?limit=` caps each group (default 5, max 20)." + ), + parameters=[ + OpenApiParameter(name="q", required=True, type=str, location=OpenApiParameter.QUERY), + OpenApiParameter(name="types", required=False, type=str, location=OpenApiParameter.QUERY), + OpenApiParameter(name="limit", required=False, type=int, location=OpenApiParameter.QUERY), + ], + ) + def get(self, request): + q = (request.query_params.get("q") or "").strip() + if not q: + return Response({"detail": "missing `q`"}, status=400) + try: + limit = max(1, min(int(request.query_params.get("limit") or 5), 20)) + except ValueError: + limit = 5 + types_raw = request.query_params.get("types") or "courses,instructors" + wanted = {t.strip() for t in types_raw.split(",") if t.strip()} + + out: dict = {"q": q} + + if "courses" in wanted: + cqs = ( + Course.objects.filter(status=CourseStatus.PUBLISHED) + .filter( + Q(title__icontains=q) + | Q(title_en__icontains=q) + | Q(title_fa__icontains=q) + | Q(tagline__icontains=q) + ) + .select_related("instructor", "category") + ) + cqs = annotate_course_stats(cqs)[:limit] + out["courses"] = CourseListSerializer(cqs, many=True, context={"request": request}).data + + if "instructors" in wanted: + iqs = ( + _annotate_instructor_stats(_instructor_qs()) + .filter(full_name__icontains=q) + )[:limit] + out["instructors"] = InstructorListItemSerializer( + iqs, many=True, context={"request": request} + ).data + + if ( + "lessons" in wanted + and request.user.is_authenticated + and getattr(request.user, "role", None) == Role.STUDENT + ): + enrolled_course_ids = list( + Enrollment.objects.filter(student=request.user).values_list("course_id", flat=True) + ) + lqs = ( + Lesson.objects.filter( + section__course_id__in=enrolled_course_ids, + status=LessonStatus.PUBLISHED, + ) + .filter( + Q(title__icontains=q) | Q(title_en__icontains=q) | Q(title_fa__icontains=q) + ) + .select_related("section__course") + )[:limit] + out["lessons"] = _LessonResultSerializer(lqs, many=True).data + + return Response(out) diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..8371bd2 --- /dev/null +++ b/apps/core/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from . import views +from .search import GlobalSearchView + +app_name = "core" + +urlpatterns = [ + path("", views.home, name="home"), +] + +# Mounted directly under /api/search/ from the project urlconf. +api_urlpatterns = [ + path("search/", GlobalSearchView.as_view(), name="global-search"), +] diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..6886985 --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,17 @@ +from django.http import HttpResponse +from django.utils.translation import get_language, gettext as _ + + +def home(request): + lang = get_language() + body = ( + f"\n" + f"\n" + f"ilo\n" + f"\n" + f"

{_('ilo backend is running')}

\n" + f"

{_('Active language')}: {lang}

\n" + f"

EN | FA

\n" + f"\n" + ) + return HttpResponse(body) diff --git a/apps/courses/__init__.py b/apps/courses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/courses/admin.py b/apps/courses/admin.py new file mode 100644 index 0000000..b538bda --- /dev/null +++ b/apps/courses/admin.py @@ -0,0 +1,62 @@ +from django.contrib import admin +from modeltranslation.admin import TabbedTranslationAdmin + +from .models import Category, Course, CourseFAQ, CourseHighlight, Lesson, Section + + +@admin.register(Category) +class CategoryAdmin(TabbedTranslationAdmin): + list_display = ("name", "slug", "order") + search_fields = ("name", "slug") + ordering = ("order", "slug") + + +class CourseHighlightInline(admin.TabularInline): + model = CourseHighlight + extra = 0 + + +class CourseFAQInline(admin.TabularInline): + model = CourseFAQ + extra = 0 + + +class SectionInline(admin.TabularInline): + model = Section + extra = 0 + fields = ("title", "order") + show_change_link = True + + +@admin.register(Course) +class CourseAdmin(TabbedTranslationAdmin): + list_display = ("title", "instructor", "category", "level", "status", "created_at") + list_filter = ("status", "level", "category") + search_fields = ("title", "slug") + raw_id_fields = ("instructor",) + readonly_fields = ("created_at", "updated_at", "published_at") + prepopulated_fields = {} + inlines = [CourseHighlightInline, CourseFAQInline, SectionInline] + + +class LessonInline(admin.TabularInline): + model = Lesson + extra = 0 + fields = ("title", "kind", "order", "is_preview", "status") + show_change_link = True + + +@admin.register(Section) +class SectionAdmin(TabbedTranslationAdmin): + list_display = ("title", "course", "order") + list_filter = ("course",) + raw_id_fields = ("course",) + inlines = [LessonInline] + + +@admin.register(Lesson) +class LessonAdmin(TabbedTranslationAdmin): + list_display = ("title", "section", "kind", "status", "is_preview", "order") + list_filter = ("kind", "status", "is_preview") + search_fields = ("title",) + raw_id_fields = ("section",) diff --git a/apps/courses/api/__init__.py b/apps/courses/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/courses/api/filters.py b/apps/courses/api/filters.py new file mode 100644 index 0000000..8859c3d --- /dev/null +++ b/apps/courses/api/filters.py @@ -0,0 +1,14 @@ +from django_filters import rest_framework as df + +from apps.courses.models import Course + + +class PublicCourseFilter(df.FilterSet): + category = df.CharFilter(field_name="category__slug", lookup_expr="exact") + level = df.CharFilter(field_name="level", lookup_expr="exact") + instructor = df.NumberFilter(field_name="instructor_id", lookup_expr="exact") + language = df.CharFilter(field_name="language", lookup_expr="exact") + + class Meta: + model = Course + fields = ["category", "level", "instructor", "language"] diff --git a/apps/courses/api/permissions.py b/apps/courses/api/permissions.py new file mode 100644 index 0000000..39f884b --- /dev/null +++ b/apps/courses/api/permissions.py @@ -0,0 +1,44 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + +from apps.accounts.models import Role + + +class IsTeacher(BasePermission): + message = "Only teachers can perform this action." + + def has_permission(self, request, view): + u = request.user + return bool(u and u.is_authenticated and u.role == Role.TEACHER) + + +class IsApprovedTeacher(BasePermission): + message = "Your teacher profile must be approved by an admin first." + + def has_permission(self, request, view): + u = request.user + if not (u and u.is_authenticated and u.role == Role.TEACHER): + return False + tp = getattr(u, "teacher_profile", None) + return bool(tp and tp.is_approved) + + +class IsCourseOwner(BasePermission): + """Object-level: the course (or its parent for sections/lessons) must be owned by the request user.""" + + message = "You do not own this course." + + def has_object_permission(self, request, view, obj): + from apps.courses.models import Course, Lesson, Section + + if isinstance(obj, Course): + return obj.instructor_id == request.user.id + if isinstance(obj, Section): + return obj.course.instructor_id == request.user.id + if isinstance(obj, Lesson): + return obj.section.course.instructor_id == request.user.id + return False + + +class ReadOnly(BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS diff --git a/apps/courses/api/queries.py b/apps/courses/api/queries.py new file mode 100644 index 0000000..11f5e77 --- /dev/null +++ b/apps/courses/api/queries.py @@ -0,0 +1,70 @@ +"""Reusable queryset annotations for the Course API. + +Centralised here so list + detail views always return the same shape, and +expensive aggregations are computed via correlated subqueries (single SQL +statement, no Cartesian explosion across multi-relation joins). +""" + +from datetime import timedelta + +from django.db.models import Count, IntegerField, OuterRef, Subquery, Sum +from django.db.models.functions import Coalesce +from django.utils import timezone + + +def _published_lesson_qs(): + from apps.courses.models import Lesson, LessonStatus + return Lesson.objects.filter(status=LessonStatus.PUBLISHED) + + +def annotate_course_stats(qs, *, bestseller_window_days: int = 90): + """Annotate Course queryset with `lesson_count`, `total_minutes_seconds`, + `enrolled_count`, and `popularity` (= paid orders in the last + `bestseller_window_days`). Each is a correlated subquery so they don't + interact — safe to combine.""" + + from apps.courses.models import Lesson, LessonStatus + from apps.enrollments.models import Enrollment + from apps.payments.models import Order, OrderStatus + + cutoff = timezone.now() - timedelta(days=bestseller_window_days) + + lesson_count_sq = ( + Lesson.objects.filter(section__course=OuterRef("pk"), status=LessonStatus.PUBLISHED) + .order_by() + .values("section__course") + .annotate(c=Count("id")) + .values("c")[:1] + ) + duration_sum_sq = ( + Lesson.objects.filter(section__course=OuterRef("pk"), status=LessonStatus.PUBLISHED) + .order_by() + .values("section__course") + .annotate(s=Coalesce(Sum("duration_seconds"), 0)) + .values("s")[:1] + ) + enrolled_count_sq = ( + Enrollment.objects.filter(course=OuterRef("pk")) + .order_by() + .values("course") + .annotate(c=Count("id")) + .values("c")[:1] + ) + popularity_sq = ( + Order.objects.filter( + course=OuterRef("pk"), + status=OrderStatus.PAID, + created_at__gte=cutoff, + ) + .order_by() + .values("course") + .annotate(c=Count("id")) + .values("c")[:1] + ) + + return qs.annotate( + lesson_count=Coalesce(Subquery(lesson_count_sq, output_field=IntegerField()), 0), + total_minutes_seconds=Coalesce(Subquery(duration_sum_sq, output_field=IntegerField()), 0), + enrolled_count=Coalesce(Subquery(enrolled_count_sq, output_field=IntegerField()), 0), + popularity=Coalesce(Subquery(popularity_sq, output_field=IntegerField()), 0), + ) diff --git a/apps/courses/api/serializers.py b/apps/courses/api/serializers.py new file mode 100644 index 0000000..dac51d1 --- /dev/null +++ b/apps/courses/api/serializers.py @@ -0,0 +1,434 @@ +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone +from rest_framework import serializers + +from apps.courses.models import ( + Category, + Course, + CourseFAQ, + CourseHighlight, + Lesson, + Section, +) + + +# --- Categories --------------------------------------------------------------- + +class CategorySerializer(serializers.ModelSerializer): + course_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = Category + fields = ["id", "slug", "name", "name_fa", "name_en", "order", "course_count"] + read_only_fields = ["id", "course_count"] + + +# --- Course bits -------------------------------------------------------------- + +class CourseHighlightSerializer(serializers.ModelSerializer): + """Read-only nested representation.""" + + class Meta: + model = CourseHighlight + fields = ["id", "text", "order"] + read_only_fields = ["id"] + + +class CourseHighlightWriteSerializer(serializers.ModelSerializer): + course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all()) + + class Meta: + model = CourseHighlight + fields = ["id", "course", "text", "order"] + read_only_fields = ["id"] + + +class CourseFAQSerializer(serializers.ModelSerializer): + """Read-only nested representation.""" + + class Meta: + model = CourseFAQ + fields = ["id", "question", "answer", "order"] + read_only_fields = ["id"] + + +class CourseFAQWriteSerializer(serializers.ModelSerializer): + course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all()) + + class Meta: + model = CourseFAQ + fields = ["id", "course", "question", "answer", "order"] + read_only_fields = ["id"] + + +# --- Lessons ------------------------------------------------------------------ + +class LessonWriteSerializer(serializers.ModelSerializer): + """Used for create/update by the instructor.""" + + section = serializers.PrimaryKeyRelatedField(queryset=Section.objects.all()) + + class Meta: + model = Lesson + fields = [ + "id", + "section", + "title", + "kind", + "body", + "video_url", + "pdf_file", + "duration_seconds", + "is_preview", + "is_downloadable", + "status", + "order", + ] + read_only_fields = ["id"] + + def validate(self, attrs): + kind = attrs.get("kind") or getattr(self.instance, "kind", None) + body = attrs.get("body") or (getattr(self.instance, "body", "") if self.instance else "") + video_url = attrs.get("video_url") or ( + getattr(self.instance, "video_url", "") if self.instance else "" + ) + pdf_file = attrs.get("pdf_file") or ( + getattr(self.instance, "pdf_file", None) if self.instance else None + ) + + if kind == "video" and not video_url: + raise serializers.ValidationError({"video_url": "Required when kind=video."}) + if kind == "pdf" and not pdf_file: + raise serializers.ValidationError({"pdf_file": "Required when kind=pdf."}) + if kind in {"text", "task"} and not body: + raise serializers.ValidationError({"body": "Required when kind=text or kind=task."}) + return attrs + + +class LessonReadSerializer(serializers.ModelSerializer): + """Used for nested reads. Hides body/video_url/pdf for non-preview lessons in public detail + (the public detail serializer overrides those with a PreviewLessonSerializer below).""" + + class Meta: + model = Lesson + fields = [ + "id", + "title", + "kind", + "body", + "video_url", + "pdf_file", + "duration_seconds", + "is_preview", + "is_downloadable", + "status", + "order", + ] + + +class PublicLessonSerializer(serializers.ModelSerializer): + """For public catalog: only expose content for free previews; otherwise hide it.""" + + body = serializers.SerializerMethodField() + video_url = serializers.SerializerMethodField() + pdf_file = serializers.SerializerMethodField() + + class Meta: + model = Lesson + fields = [ + "id", + "title", + "kind", + "body", + "video_url", + "pdf_file", + "duration_seconds", + "is_preview", + "is_downloadable", + "order", + ] + + def _gated(self, obj, attr): + return getattr(obj, attr) if obj.is_preview else None + + def get_body(self, obj): + return self._gated(obj, "body") + + def get_video_url(self, obj): + return self._gated(obj, "video_url") + + def get_pdf_file(self, obj): + f = self._gated(obj, "pdf_file") + request = self.context.get("request") + if f and request: + return request.build_absolute_uri(f.url) + return f.url if f else None + + +# --- Sections ----------------------------------------------------------------- + +class SectionWriteSerializer(serializers.ModelSerializer): + course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all()) + + class Meta: + model = Section + fields = ["id", "course", "title", "order"] + read_only_fields = ["id"] + + +class SectionReadSerializer(serializers.ModelSerializer): + lessons = LessonReadSerializer(many=True, read_only=True) + + class Meta: + model = Section + fields = ["id", "title", "order", "lessons"] + + +class PublicSectionSerializer(serializers.ModelSerializer): + lessons = serializers.SerializerMethodField() + + class Meta: + model = Section + fields = ["id", "title", "order", "lessons"] + + def get_lessons(self, obj): + published = obj.lessons.filter(status="published") + return PublicLessonSerializer(published, many=True, context=self.context).data + + +# --- Courses ------------------------------------------------------------------ + +class _CourseStatsMixin(serializers.Serializer): + """Adds annotated stats + computed badges to any Course serializer. + + Requires the queryset to be passed through `annotate_course_stats()` first; + otherwise the values default to 0 / False.""" + + lesson_count = serializers.IntegerField(read_only=True, default=0) + enrolled_count = serializers.IntegerField(read_only=True, default=0) + total_minutes_seconds = serializers.IntegerField(read_only=True, default=0) + is_new = serializers.SerializerMethodField() + is_bestseller = serializers.SerializerMethodField() + discount_active = serializers.SerializerMethodField() + + def get_is_new(self, obj): + window = getattr(settings, "COURSE_NEW_WINDOW_DAYS", 30) + return (timezone.now() - obj.created_at) < timedelta(days=window) + + def get_is_bestseller(self, obj): + threshold = getattr(settings, "COURSE_BESTSELLER_THRESHOLD", 10) + return getattr(obj, "popularity", 0) >= threshold + + def get_discount_active(self, obj): + return obj.discount_is_active + + +class CourseListSerializer(_CourseStatsMixin, serializers.ModelSerializer): + """Shape used in /api/instructor/courses/ list and /api/courses/ list.""" + + instructor_id = serializers.IntegerField(read_only=True) + instructor_name = serializers.CharField(source="instructor.full_name", read_only=True) + category = CategorySerializer(read_only=True) + cover_image = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + "id", + "slug", + "title", + "tagline", + "level", + "language", + "color", + "cover_image", + "status", + "is_coming_soon", + "category", + "instructor_id", + "instructor_name", + "price_toman", + "original_price_toman", + "created_at", + "updated_at", + "published_at", + "lesson_count", + "enrolled_count", + "total_minutes_seconds", + "is_new", + "is_bestseller", + "discount_active", + "discount_starts_at", + "discount_ends_at", + ] + read_only_fields = fields + + def get_cover_image(self, obj): + if not obj.cover_image: + return None + request = self.context.get("request") + if request: + return request.build_absolute_uri(obj.cover_image.url) + return obj.cover_image.url + + +class CourseInstructorWriteSerializer(serializers.ModelSerializer): + """Used by the instructor for create / update. Slug is auto-generated if omitted.""" + + class Meta: + model = Course + fields = [ + "id", + "slug", + "title", + "tagline", + "description", + "level", + "language", + "category", + "cover_image", + "color", + "is_coming_soon", + "price_toman", + "original_price_toman", + ] + read_only_fields = ["id", "slug"] + + +class CourseInstructorDetailSerializer(_CourseStatsMixin, serializers.ModelSerializer): + """Full instructor-side view: includes drafts, sections, lessons, highlights, FAQ.""" + + category = CategorySerializer(read_only=True) + sections = SectionReadSerializer(many=True, read_only=True) + highlights = CourseHighlightSerializer(many=True, read_only=True) + faqs = CourseFAQSerializer(many=True, read_only=True) + cover_image = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + "id", + "slug", + "title", + "tagline", + "description", + "level", + "language", + "color", + "cover_image", + "status", + "is_coming_soon", + "published_at", + "created_at", + "updated_at", + "category", + "price_toman", + "original_price_toman", + "sections", + "highlights", + "faqs", + "lesson_count", + "enrolled_count", + "total_minutes_seconds", + "is_new", + "is_bestseller", + "discount_active", + "discount_starts_at", + "discount_ends_at", + ] + read_only_fields = fields + + def get_cover_image(self, obj): + if not obj.cover_image: + return None + request = self.context.get("request") + if request: + return request.build_absolute_uri(obj.cover_image.url) + return obj.cover_image.url + + +class CoursePublicDetailSerializer(_CourseStatsMixin, serializers.ModelSerializer): + """Public course detail: only published sections + published lessons; preview-gated content.""" + + instructor_id = serializers.IntegerField(read_only=True) + instructor_name = serializers.CharField(source="instructor.full_name", read_only=True) + instructor = serializers.SerializerMethodField() + category = CategorySerializer(read_only=True) + sections = serializers.SerializerMethodField() + highlights = CourseHighlightSerializer(many=True, read_only=True) + faqs = CourseFAQSerializer(many=True, read_only=True) + cover_image = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + "id", + "slug", + "title", + "tagline", + "description", + "level", + "language", + "color", + "cover_image", + "published_at", + "created_at", + "updated_at", + "category", + "instructor_id", + "instructor_name", + "instructor", + "price_toman", + "original_price_toman", + "highlights", + "faqs", + "sections", + "lesson_count", + "enrolled_count", + "total_minutes_seconds", + "is_new", + "is_bestseller", + "discount_active", + "discount_starts_at", + "discount_ends_at", + "is_coming_soon", + ] + read_only_fields = fields + + def get_instructor(self, obj): + # Embedded "About instructor" card. Falls back gracefully if no profile yet. + u = obj.instructor + tp = getattr(u, "teacher_profile", None) + request = self.context.get("request") + avatar_url = None + if tp and tp.avatar: + avatar_url = request.build_absolute_uri(tp.avatar.url) if request else tp.avatar.url + return { + "id": u.id, + "full_name": u.full_name, + "headline": getattr(tp, "headline", "") if tp else "", + "bio": getattr(tp, "bio", "") if tp else "", + "is_verified": getattr(tp, "is_verified", False) if tp else False, + "years_experience": getattr(tp, "years_experience", None) if tp else None, + "avatar": avatar_url, + } + + def get_cover_image(self, obj): + if not obj.cover_image: + return None + request = self.context.get("request") + if request: + return request.build_absolute_uri(obj.cover_image.url) + return obj.cover_image.url + + def get_sections(self, obj): + published = obj.sections.all().prefetch_related("lessons") + return PublicSectionSerializer(published, many=True, context=self.context).data + + +# --- Reorder ------------------------------------------------------------------ + +class IDListSerializer(serializers.Serializer): + ids = serializers.ListField(child=serializers.IntegerField(), allow_empty=False) diff --git a/apps/courses/api/urls.py b/apps/courses/api/urls.py new file mode 100644 index 0000000..570ed81 --- /dev/null +++ b/apps/courses/api/urls.py @@ -0,0 +1,29 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + CategoryListView, + InstructorCourseViewSet, + InstructorFAQViewSet, + InstructorHighlightViewSet, + InstructorLessonViewSet, + InstructorSectionViewSet, + PublicCourseDetailView, + PublicCourseListView, +) + +instructor_router = DefaultRouter() +instructor_router.register(r"courses", InstructorCourseViewSet, basename="instructor-course") +instructor_router.register(r"sections", InstructorSectionViewSet, basename="instructor-section") +instructor_router.register(r"lessons", InstructorLessonViewSet, basename="instructor-lesson") +instructor_router.register(r"highlights", InstructorHighlightViewSet, basename="instructor-highlight") +instructor_router.register(r"faqs", InstructorFAQViewSet, basename="instructor-faq") + +urlpatterns = [ + # public + path("categories/", CategoryListView.as_view(), name="categories"), + path("courses/", PublicCourseListView.as_view(), name="public-courses"), + path("courses//", PublicCourseDetailView.as_view(), name="public-course-detail"), + # instructor surface + path("instructor/", include((instructor_router.urls, "instructor"))), +] diff --git a/apps/courses/api/views.py b/apps/courses/api/views.py new file mode 100644 index 0000000..67f6da4 --- /dev/null +++ b/apps/courses/api/views.py @@ -0,0 +1,301 @@ +from django.db import transaction +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from django.db.models import Count, Q + +from apps.courses.models import ( + Category, + Course, + CourseFAQ, + CourseHighlight, + CourseStatus, + Lesson, + Section, +) + +from .filters import PublicCourseFilter +from .permissions import IsApprovedTeacher, IsCourseOwner, IsTeacher +from .queries import annotate_course_stats +from .serializers import ( + CategorySerializer, + CourseFAQSerializer, + CourseFAQWriteSerializer, + CourseHighlightSerializer, + CourseHighlightWriteSerializer, + CourseInstructorDetailSerializer, + CourseInstructorWriteSerializer, + CourseListSerializer, + CoursePublicDetailSerializer, + IDListSerializer, + LessonReadSerializer, + LessonWriteSerializer, + SectionReadSerializer, + SectionWriteSerializer, +) + + +# ============================================================================= +# Public surface +# ============================================================================= + +@extend_schema( + tags=["categories"], + summary="List categories", + description="Includes a `course_count` of currently-published courses per category.", +) +class CategoryListView(ListAPIView): + permission_classes = [AllowAny] + pagination_class = None + serializer_class = CategorySerializer + + def get_queryset(self): + return Category.objects.annotate( + course_count=Count( + "courses", + filter=Q(courses__status=CourseStatus.PUBLISHED), + distinct=True, + ) + ).order_by("order", "slug") + + +@extend_schema( + tags=["courses-public"], + summary="Browse published courses", + description=( + "Public catalog. Filters: `category` (slug), `level`, `instructor` (id), `language`. " + "Search: `?search=...` over title/tagline. " + "Ordering: `created_at`, `published_at`, `popularity` (paid orders in the last " + "`COURSE_BESTSELLER_WINDOW_DAYS`). Use `?ordering=-popularity` for the bestsellers feed." + ), +) +class PublicCourseListView(ListAPIView): + permission_classes = [AllowAny] + serializer_class = CourseListSerializer + filterset_class = PublicCourseFilter + search_fields = ["title", "title_en", "title_fa", "tagline", "tagline_en", "tagline_fa"] + ordering_fields = ["created_at", "published_at", "popularity"] + ordering = ["-published_at"] + + def get_queryset(self): + qs = ( + Course.objects.filter(status=CourseStatus.PUBLISHED) + .select_related("instructor", "category") + ) + return annotate_course_stats(qs) + + +@extend_schema( + tags=["courses-public"], + summary="Public course detail", + description=( + "Returns the full structure (sections + published lessons + highlights + FAQ) plus " + "an embedded `instructor` card (bio, headline, verified badge, years_experience, avatar) " + "and the same stats/badges as the list endpoint (`lesson_count`, `enrolled_count`, " + "`total_minutes_seconds`, `is_new`, `is_bestseller`, `is_coming_soon`). " + "Locked lesson content is null unless `is_preview=true`." + ), +) +class PublicCourseDetailView(RetrieveAPIView): + permission_classes = [AllowAny] + serializer_class = CoursePublicDetailSerializer + lookup_field = "slug" + + def get_queryset(self): + qs = ( + Course.objects.filter(status=CourseStatus.PUBLISHED) + .select_related("instructor", "instructor__teacher_profile", "category") + .prefetch_related("highlights", "faqs", "sections__lessons") + ) + return annotate_course_stats(qs) + + +# ============================================================================= +# Instructor surface +# ============================================================================= + +class InstructorCourseViewSet(viewsets.ModelViewSet): + """CRUD over the instructor's own courses, plus publish/unpublish actions.""" + + permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner] + queryset = Course.objects.all().select_related("instructor", "category") + lookup_field = "pk" + filterset_fields = ["status", "category", "is_coming_soon"] + search_fields = ["title", "title_en", "title_fa"] + ordering_fields = ["created_at", "updated_at", "published_at", "popularity"] + + def get_queryset(self): + qs = super().get_queryset().filter(instructor=self.request.user) + return annotate_course_stats(qs) + + def get_serializer_class(self): + if self.action in {"create", "update", "partial_update"}: + return CourseInstructorWriteSerializer + if self.action == "list": + return CourseListSerializer + return CourseInstructorDetailSerializer + + def perform_create(self, serializer): + serializer.save(instructor=self.request.user) + + @extend_schema( + tags=["courses-instructor"], + summary="Publish course", + description="Sets status=published. Requires the instructor's TeacherProfile to be approved.", + request=None, + responses={ + 200: CourseInstructorDetailSerializer, + 403: OpenApiResponse(description="Teacher profile not approved or not owner."), + }, + ) + @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated, IsApprovedTeacher, IsCourseOwner]) + def publish(self, request, pk=None): + course = self.get_object() + course.publish() + return Response(CourseInstructorDetailSerializer(course, context={"request": request}).data) + + @extend_schema( + tags=["courses-instructor"], + summary="Unpublish course (back to draft)", + request=None, + responses={200: CourseInstructorDetailSerializer}, + ) + @action(detail=True, methods=["post"]) + def unpublish(self, request, pk=None): + course = self.get_object() + course.unpublish() + return Response(CourseInstructorDetailSerializer(course, context={"request": request}).data) + + @extend_schema( + tags=["courses-instructor"], + summary="Reorder sections within a course", + description="Body: `{\"ids\": [section_id, ...]}` — array order = new order.", + request=IDListSerializer, + responses={204: OpenApiResponse(description="Reordered.")}, + ) + @action(detail=True, methods=["post"], url_path="sections/reorder") + def reorder_sections(self, request, pk=None): + course = self.get_object() + ser = IDListSerializer(data=request.data) + ser.is_valid(raise_exception=True) + ids = ser.validated_data["ids"] + sections = list(course.sections.filter(id__in=ids)) + if {s.id for s in sections} != set(ids): + raise ValidationError({"ids": "Some sections do not belong to this course."}) + with transaction.atomic(): + for order, sid in enumerate(ids): + Section.objects.filter(id=sid, course=course).update(order=order) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstructorSectionViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner] + serializer_class = SectionWriteSerializer + queryset = Section.objects.all().select_related("course") + filterset_fields = ["course"] + ordering_fields = ["order"] + ordering = ["order"] + + def get_queryset(self): + return super().get_queryset().filter(course__instructor=self.request.user) + + def get_serializer_class(self): + if self.action in {"list", "retrieve"}: + return SectionReadSerializer + return SectionWriteSerializer + + def perform_create(self, serializer): + course = serializer.validated_data["course"] + if course.instructor_id != self.request.user.id: + raise PermissionDenied("You don't own this course.") + serializer.save() + + @extend_schema( + tags=["courses-instructor"], + summary="Reorder lessons within a section", + description="Body: `{\"ids\": [lesson_id, ...]}` — array order = new order.", + request=IDListSerializer, + responses={204: OpenApiResponse(description="Reordered.")}, + ) + @action(detail=True, methods=["post"], url_path="lessons/reorder") + def reorder_lessons(self, request, pk=None): + section = self.get_object() + ser = IDListSerializer(data=request.data) + ser.is_valid(raise_exception=True) + ids = ser.validated_data["ids"] + lessons = list(section.lessons.filter(id__in=ids)) + if {l.id for l in lessons} != set(ids): + raise ValidationError({"ids": "Some lessons do not belong to this section."}) + with transaction.atomic(): + for order, lid in enumerate(ids): + Lesson.objects.filter(id=lid, section=section).update(order=order) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstructorLessonViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner] + serializer_class = LessonWriteSerializer + queryset = Lesson.objects.all().select_related("section__course") + filterset_fields = ["section", "section__course", "kind", "status", "is_preview"] + ordering_fields = ["order"] + ordering = ["order"] + + def get_queryset(self): + return super().get_queryset().filter(section__course__instructor=self.request.user) + + def get_serializer_class(self): + if self.action in {"list", "retrieve"}: + return LessonReadSerializer + return LessonWriteSerializer + + def perform_create(self, serializer): + section = serializer.validated_data["section"] + if section.course.instructor_id != self.request.user.id: + raise PermissionDenied("You don't own this course.") + serializer.save() + + +class _CourseScopedViewSet(viewsets.ModelViewSet): + """Shared logic for highlights + FAQs: filter to the requester's courses, enforce ownership on write.""" + + permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner] + filterset_fields = ["course"] + + def get_queryset(self): + return super().get_queryset().filter(course__instructor=self.request.user) + + def perform_create(self, serializer): + course = serializer.validated_data["course"] + if course.instructor_id != self.request.user.id: + raise PermissionDenied("You don't own this course.") + serializer.save() + + +@extend_schema(tags=["courses-instructor"]) +class InstructorHighlightViewSet(_CourseScopedViewSet): + """Bullet-list highlights shown on the public course detail.""" + + queryset = CourseHighlight.objects.all().select_related("course") + + def get_serializer_class(self): + if self.action in {"create", "update", "partial_update"}: + return CourseHighlightWriteSerializer + return CourseHighlightSerializer + + +@extend_schema(tags=["courses-instructor"]) +class InstructorFAQViewSet(_CourseScopedViewSet): + """FAQ entries shown on the public course detail.""" + + queryset = CourseFAQ.objects.all().select_related("course") + + def get_serializer_class(self): + if self.action in {"create", "update", "partial_update"}: + return CourseFAQWriteSerializer + return CourseFAQSerializer diff --git a/apps/courses/apps.py b/apps/courses/apps.py new file mode 100644 index 0000000..12c5d06 --- /dev/null +++ b/apps/courses/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoursesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.courses" + label = "courses" diff --git a/apps/courses/migrations/0001_initial.py b/apps/courses/migrations/0001_initial.py new file mode 100644 index 0000000..9d366fc --- /dev/null +++ b/apps/courses/migrations/0001_initial.py @@ -0,0 +1,158 @@ +# Generated by Django 5.1.15 on 2026-05-02 15:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(max_length=64, unique=True, verbose_name='slug')), + ('name', models.CharField(max_length=120, verbose_name='name')), + ('name_en', models.CharField(max_length=120, null=True, verbose_name='name')), + ('name_fa', models.CharField(max_length=120, null=True, verbose_name='name')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ], + options={ + 'verbose_name': 'category', + 'verbose_name_plural': 'categories', + 'ordering': ['order', 'slug'], + }, + ), + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(max_length=80, unique=True, verbose_name='slug')), + ('title', models.CharField(max_length=200, verbose_name='title')), + ('title_en', models.CharField(max_length=200, null=True, verbose_name='title')), + ('title_fa', models.CharField(max_length=200, null=True, verbose_name='title')), + ('tagline', models.CharField(blank=True, max_length=300, verbose_name='tagline')), + ('tagline_en', models.CharField(blank=True, max_length=300, null=True, verbose_name='tagline')), + ('tagline_fa', models.CharField(blank=True, max_length=300, null=True, verbose_name='tagline')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('description_en', models.TextField(blank=True, null=True, verbose_name='description')), + ('description_fa', models.TextField(blank=True, null=True, verbose_name='description')), + ('level', models.CharField(choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'), ('all', 'All levels')], default='beginner', max_length=16, verbose_name='level')), + ('language', models.CharField(default='fa', max_length=8, verbose_name='language')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='cover image')), + ('color', models.CharField(blank=True, help_text='CSS color token used by the UI.', max_length=64, verbose_name='color')), + ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=16, verbose_name='status')), + ('published_at', models.DateTimeField(blank=True, null=True, verbose_name='published at')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='courses', to='courses.category')), + ('instructor', models.ForeignKey(limit_choices_to={'role': 'teacher'}, on_delete=django.db.models.deletion.PROTECT, related_name='courses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'course', + 'verbose_name_plural': 'courses', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='CourseFAQ', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.CharField(max_length=300, verbose_name='question')), + ('question_en', models.CharField(max_length=300, null=True, verbose_name='question')), + ('question_fa', models.CharField(max_length=300, null=True, verbose_name='question')), + ('answer', models.TextField(verbose_name='answer')), + ('answer_en', models.TextField(null=True, verbose_name='answer')), + ('answer_fa', models.TextField(null=True, verbose_name='answer')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='faqs', to='courses.course')), + ], + options={ + 'verbose_name': 'FAQ', + 'verbose_name_plural': 'FAQs', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='CourseHighlight', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.CharField(max_length=200, verbose_name='text')), + ('text_en', models.CharField(max_length=200, null=True, verbose_name='text')), + ('text_fa', models.CharField(max_length=200, null=True, verbose_name='text')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='highlights', to='courses.course')), + ], + options={ + 'verbose_name': 'highlight', + 'verbose_name_plural': 'highlights', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='Section', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='title')), + ('title_en', models.CharField(max_length=200, null=True, verbose_name='title')), + ('title_fa', models.CharField(max_length=200, null=True, verbose_name='title')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='courses.course')), + ], + options={ + 'verbose_name': 'section', + 'verbose_name_plural': 'sections', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='title')), + ('title_en', models.CharField(max_length=200, null=True, verbose_name='title')), + ('title_fa', models.CharField(max_length=200, null=True, verbose_name='title')), + ('kind', models.CharField(choices=[('video', 'Video'), ('text', 'Text'), ('pdf', 'PDF'), ('task', 'Task'), ('quiz', 'Quiz')], max_length=16, verbose_name='kind')), + ('body', models.TextField(blank=True, help_text='Markdown body for text/task lessons.', verbose_name='body')), + ('body_en', models.TextField(blank=True, help_text='Markdown body for text/task lessons.', null=True, verbose_name='body')), + ('body_fa', models.TextField(blank=True, help_text='Markdown body for text/task lessons.', null=True, verbose_name='body')), + ('video_url', models.URLField(blank=True, max_length=500, verbose_name='video URL')), + ('pdf_file', models.FileField(blank=True, null=True, upload_to='courses/pdfs/', verbose_name='PDF')), + ('duration_seconds', models.PositiveIntegerField(blank=True, null=True, verbose_name='duration (seconds)')), + ('is_preview', models.BooleanField(default=False, verbose_name='free preview')), + ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=16, verbose_name='status')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='courses.section')), + ], + options={ + 'verbose_name': 'lesson', + 'verbose_name_plural': 'lessons', + 'ordering': ['order', 'id'], + }, + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['instructor', 'status'], name='courses_cou_instruc_98570d_idx'), + ), + migrations.AddIndex( + model_name='course', + index=models.Index(fields=['status', '-created_at'], name='courses_cou_status_41eebb_idx'), + ), + migrations.AddIndex( + model_name='section', + index=models.Index(fields=['course', 'order'], name='courses_sec_course__60bff3_idx'), + ), + migrations.AddIndex( + model_name='lesson', + index=models.Index(fields=['section', 'order'], name='courses_les_section_573283_idx'), + ), + ] diff --git a/apps/courses/migrations/0002_course_original_price_toman_course_price_toman.py b/apps/courses/migrations/0002_course_original_price_toman_course_price_toman.py new file mode 100644 index 0000000..e98cb3a --- /dev/null +++ b/apps/courses/migrations/0002_course_original_price_toman_course_price_toman.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-05-02 15:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='original_price_toman', + field=models.PositiveIntegerField(blank=True, help_text='Optional strike-through price for displaying a discount.', null=True, verbose_name='original price (Toman)'), + ), + migrations.AddField( + model_name='course', + name='price_toman', + field=models.PositiveIntegerField(blank=True, help_text='Null or 0 = free course.', null=True, verbose_name='price (Toman)'), + ), + ] diff --git a/apps/courses/migrations/0003_course_is_coming_soon_lesson_is_downloadable.py b/apps/courses/migrations/0003_course_is_coming_soon_lesson_is_downloadable.py new file mode 100644 index 0000000..ae09bfd --- /dev/null +++ b/apps/courses/migrations/0003_course_is_coming_soon_lesson_is_downloadable.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-05-02 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0002_course_original_price_toman_course_price_toman'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='is_coming_soon', + field=models.BooleanField(default=False, help_text="Show a 'coming soon' badge on the catalog (e.g. course is being prepared but is already listed).", verbose_name='coming soon'), + ), + migrations.AddField( + model_name='lesson', + name='is_downloadable', + field=models.BooleanField(default=False, help_text='If true, students can download the asset (mostly for video lessons).', verbose_name='downloadable'), + ), + ] diff --git a/apps/courses/migrations/0004_course_discount_ends_at_course_discount_starts_at.py b/apps/courses/migrations/0004_course_discount_ends_at_course_discount_starts_at.py new file mode 100644 index 0000000..dc1e03d --- /dev/null +++ b/apps/courses/migrations/0004_course_discount_ends_at_course_discount_starts_at.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-05-02 16:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0003_course_is_coming_soon_lesson_is_downloadable'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='discount_ends_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='discount ends at'), + ), + migrations.AddField( + model_name='course', + name='discount_starts_at', + field=models.DateTimeField(blank=True, help_text='If set together with discount_ends_at, the catalog shows an active discount countdown.', null=True, verbose_name='discount starts at'), + ), + ] diff --git a/apps/courses/migrations/__init__.py b/apps/courses/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/courses/models.py b/apps/courses/models.py new file mode 100644 index 0000000..4205cd9 --- /dev/null +++ b/apps/courses/models.py @@ -0,0 +1,231 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ + +# Ensure timezone is referenced (used by Course.discount_is_active) +_ = _ + + +class Level(models.TextChoices): + BEGINNER = "beginner", _("Beginner") + INTERMEDIATE = "intermediate", _("Intermediate") + ADVANCED = "advanced", _("Advanced") + ALL = "all", _("All levels") + + +class CourseStatus(models.TextChoices): + DRAFT = "draft", _("Draft") + PUBLISHED = "published", _("Published") + + +class LessonKind(models.TextChoices): + VIDEO = "video", _("Video") + TEXT = "text", _("Text") + PDF = "pdf", _("PDF") + TASK = "task", _("Task") + QUIZ = "quiz", _("Quiz") + + +class LessonStatus(models.TextChoices): + DRAFT = "draft", _("Draft") + PUBLISHED = "published", _("Published") + + +class Category(models.Model): + slug = models.SlugField(_("slug"), max_length=64, unique=True) + name = models.CharField(_("name"), max_length=120) + order = models.PositiveIntegerField(_("order"), default=0) + + class Meta: + verbose_name = _("category") + verbose_name_plural = _("categories") + ordering = ["order", "slug"] + + def __str__(self): + return self.name or self.slug + + +class Course(models.Model): + instructor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="courses", + limit_choices_to={"role": "teacher"}, + ) + category = models.ForeignKey( + Category, + on_delete=models.SET_NULL, + related_name="courses", + null=True, + blank=True, + ) + slug = models.SlugField(_("slug"), max_length=80, unique=True) + title = models.CharField(_("title"), max_length=200) + tagline = models.CharField(_("tagline"), max_length=300, blank=True) + description = models.TextField(_("description"), blank=True) + level = models.CharField( + _("level"), max_length=16, choices=Level.choices, default=Level.BEGINNER + ) + language = models.CharField(_("language"), max_length=8, default="fa") + cover_image = models.ImageField(_("cover image"), upload_to="courses/covers/", blank=True, null=True) + color = models.CharField(_("color"), max_length=64, blank=True, help_text=_("CSS color token used by the UI.")) + is_coming_soon = models.BooleanField( + _("coming soon"), default=False, + help_text=_("Show a 'coming soon' badge on the catalog (e.g. course is being prepared but is already listed)."), + ) + price_toman = models.PositiveIntegerField( + _("price (Toman)"), null=True, blank=True, + help_text=_("Null or 0 = free course."), + ) + original_price_toman = models.PositiveIntegerField( + _("original price (Toman)"), null=True, blank=True, + help_text=_("Optional strike-through price for displaying a discount."), + ) + discount_starts_at = models.DateTimeField( + _("discount starts at"), null=True, blank=True, + help_text=_("If set together with discount_ends_at, the catalog shows an active discount countdown."), + ) + discount_ends_at = models.DateTimeField( + _("discount ends at"), null=True, blank=True, + ) + status = models.CharField( + _("status"), max_length=16, choices=CourseStatus.choices, default=CourseStatus.DRAFT + ) + published_at = models.DateTimeField(_("published at"), null=True, blank=True) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(_("updated at"), auto_now=True) + + class Meta: + verbose_name = _("course") + verbose_name_plural = _("courses") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["instructor", "status"]), + models.Index(fields=["status", "-created_at"]), + ] + + def __str__(self): + return self.title or self.slug + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self._generate_slug() + super().save(*args, **kwargs) + + def _generate_slug(self) -> str: + base = slugify(self.title or "course", allow_unicode=False) or "course" + candidate = base[:64] + n = 2 + while Course.objects.filter(slug=candidate).exclude(pk=self.pk).exists(): + suffix = f"-{n}" + candidate = (base[: 64 - len(suffix)]) + suffix + n += 1 + return candidate + + @property + def is_published(self) -> bool: + return self.status == CourseStatus.PUBLISHED + + @property + def is_free(self) -> bool: + return not self.price_toman + + @property + def discount_is_active(self) -> bool: + if not (self.original_price_toman and self.price_toman and self.original_price_toman > self.price_toman): + return False + now = timezone.now() + if self.discount_starts_at and now < self.discount_starts_at: + return False + if self.discount_ends_at and now >= self.discount_ends_at: + return False + return True + + def publish(self): + if self.status != CourseStatus.PUBLISHED: + self.status = CourseStatus.PUBLISHED + self.published_at = self.published_at or timezone.now() + self.save(update_fields=["status", "published_at", "updated_at"]) + + def unpublish(self): + if self.status != CourseStatus.DRAFT: + self.status = CourseStatus.DRAFT + self.save(update_fields=["status", "updated_at"]) + + +class CourseHighlight(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="highlights") + text = models.CharField(_("text"), max_length=200) + order = models.PositiveIntegerField(_("order"), default=0) + + class Meta: + verbose_name = _("highlight") + verbose_name_plural = _("highlights") + ordering = ["order", "id"] + + def __str__(self): + return self.text[:60] + + +class CourseFAQ(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="faqs") + question = models.CharField(_("question"), max_length=300) + answer = models.TextField(_("answer")) + order = models.PositiveIntegerField(_("order"), default=0) + + class Meta: + verbose_name = _("FAQ") + verbose_name_plural = _("FAQs") + ordering = ["order", "id"] + + def __str__(self): + return self.question[:60] + + +class Section(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="sections") + title = models.CharField(_("title"), max_length=200) + order = models.PositiveIntegerField(_("order"), default=0) + + class Meta: + verbose_name = _("section") + verbose_name_plural = _("sections") + ordering = ["order", "id"] + indexes = [models.Index(fields=["course", "order"])] + + def __str__(self): + return self.title or f"section #{self.pk}" + + +class Lesson(models.Model): + section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="lessons") + title = models.CharField(_("title"), max_length=200) + kind = models.CharField(_("kind"), max_length=16, choices=LessonKind.choices) + body = models.TextField(_("body"), blank=True, help_text=_("Markdown body for text/task lessons.")) + video_url = models.URLField(_("video URL"), blank=True, max_length=500) + pdf_file = models.FileField(_("PDF"), upload_to="courses/pdfs/", blank=True, null=True) + duration_seconds = models.PositiveIntegerField( + _("duration (seconds)"), null=True, blank=True + ) + is_preview = models.BooleanField(_("free preview"), default=False) + is_downloadable = models.BooleanField( + _("downloadable"), default=False, + help_text=_("If true, students can download the asset (mostly for video lessons)."), + ) + status = models.CharField( + _("status"), max_length=16, choices=LessonStatus.choices, default=LessonStatus.DRAFT + ) + order = models.PositiveIntegerField(_("order"), default=0) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(_("updated at"), auto_now=True) + + class Meta: + verbose_name = _("lesson") + verbose_name_plural = _("lessons") + ordering = ["order", "id"] + indexes = [models.Index(fields=["section", "order"])] + + def __str__(self): + return self.title or f"lesson #{self.pk}" diff --git a/apps/courses/tests.py b/apps/courses/tests.py new file mode 100644 index 0000000..e9d5e95 --- /dev/null +++ b/apps/courses/tests.py @@ -0,0 +1,505 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from apps.accounts.models import Role, TeacherProfile +from apps.courses.models import ( + Category, + Course, + CourseFAQ, + CourseHighlight, + CourseStatus, + Lesson, + LessonKind, + LessonStatus, + Section, +) + +User = get_user_model() + + +# --------------------------------------------------------------------------- helpers + +def _make_teacher(phone="09120000001", name="Teacher One", approved=False): + u = User.objects.create_user(phone_number=phone, full_name=name, role=Role.TEACHER) + TeacherProfile.objects.create(user=u, is_approved=approved) + return u + + +def _make_student(phone="09120000099", name="Student"): + return User.objects.create_user(phone_number=phone, full_name=name, role=Role.STUDENT) + + +def _auth(client, user): + # tests bypass OTP and just attach the user via force_authenticate + client.force_authenticate(user=user) + + +def _course(instructor, **overrides): + fields = { + "title": "Course Title", + "tagline": "Tagline", + "description": "Description", + "level": "beginner", + "language": "fa", + } + fields.update(overrides) + return Course.objects.create(instructor=instructor, **fields) + + +# --------------------------------------------------------------------------- model tests + +class CourseModelTests(TestCase): + def test_slug_auto_generated_on_create(self): + t = _make_teacher() + c = _course(t, title="Hello World") + self.assertTrue(c.slug) + self.assertNotIn(" ", c.slug) + + def test_slug_uniqueness_collision(self): + t = _make_teacher() + a = _course(t, title="Same Title") + b = _course(t, title="Same Title") + self.assertNotEqual(a.slug, b.slug) + + def test_publish_sets_status_and_published_at(self): + t = _make_teacher() + c = _course(t) + self.assertEqual(c.status, CourseStatus.DRAFT) + self.assertIsNone(c.published_at) + c.publish() + c.refresh_from_db() + self.assertEqual(c.status, CourseStatus.PUBLISHED) + self.assertIsNotNone(c.published_at) + + +# --------------------------------------------------------------------------- public catalog + +class PublicCatalogTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _make_teacher(approved=True) + self.cat = Category.objects.create(slug="design", name="Design", order=0) + + self.published = _course(self.t, title="Published", category=self.cat) + self.published.publish() + self.draft = _course(self.t, title="Draft", category=self.cat) + + def test_list_excludes_drafts(self): + r = self.client.get("/api/courses/") + self.assertEqual(r.status_code, 200) + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(self.published.id, ids) + self.assertNotIn(self.draft.id, ids) + + def test_filter_by_category_slug(self): + other = Category.objects.create(slug="dev", name="Dev") + c2 = _course(self.t, title="Other", category=other) + c2.publish() + r = self.client.get("/api/courses/?category=design") + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(self.published.id, ids) + self.assertNotIn(c2.id, ids) + + def test_search_by_title(self): + r = self.client.get("/api/courses/?search=Publish") + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(self.published.id, ids) + + def test_detail_404_for_draft(self): + r = self.client.get(f"/api/courses/{self.draft.slug}/") + self.assertEqual(r.status_code, 404) + + def test_detail_for_published(self): + r = self.client.get(f"/api/courses/{self.published.slug}/") + self.assertEqual(r.status_code, 200) + body = r.json() + self.assertEqual(body["id"], self.published.id) + self.assertIn("sections", body) + self.assertIn("highlights", body) + self.assertIn("faqs", body) + + def test_category_includes_course_count(self): + r = self.client.get("/api/categories/") + self.assertEqual(r.status_code, 200) + rows = r.json() + design_row = next(c for c in rows if c["slug"] == "design") + # Only 1 published course in `design` (`self.published`); `self.draft` is draft + self.assertEqual(design_row["course_count"], 1) + + def test_course_list_includes_stats_and_badges(self): + r = self.client.get("/api/courses/") + row = next(c for c in r.json()["results"] if c["id"] == self.published.id) + self.assertIn("lesson_count", row) + self.assertIn("enrolled_count", row) + self.assertIn("total_minutes_seconds", row) + self.assertIn("is_new", row) + self.assertIn("is_bestseller", row) + self.assertIn("is_coming_soon", row) + # Newly created course → is_new=True + self.assertTrue(row["is_new"]) + # No paid orders → is_bestseller=False + self.assertFalse(row["is_bestseller"]) + + def test_course_list_ordering_by_popularity(self): + from apps.payments.models import Gateway, Order, OrderStatus + other = _course(self.t, title="Hot", category=self.cat) + other.publish() + # Create 12 paid orders for `other` to push it above the bestseller threshold (10) + from django.contrib.auth import get_user_model + u_model = get_user_model() + for i in range(12): + stu = u_model.objects.create_user( + phone_number=f"0911000{i:04d}", full_name=f"S{i}", role="student" + ) + Order.objects.create( + student=stu, course=other, amount_toman=1000, gateway=Gateway.CONSOLE, + status=OrderStatus.PAID, + ) + r = self.client.get("/api/courses/?ordering=-popularity") + results = r.json()["results"] + ids = [c["id"] for c in results] + self.assertEqual(ids[0], other.id) + # `other` should now be marked bestseller + hot = next(c for c in results if c["id"] == other.id) + self.assertTrue(hot["is_bestseller"]) + + def test_lesson_content_gated_unless_preview(self): + sec = Section.objects.create(course=self.published, title="S1", order=0) + free = Lesson.objects.create( + section=sec, + title="Free preview", + kind=LessonKind.VIDEO, + video_url="https://example.com/v.mp4", + status=LessonStatus.PUBLISHED, + is_preview=True, + is_downloadable=True, + order=0, + ) + locked = Lesson.objects.create( + section=sec, + title="Locked", + kind=LessonKind.VIDEO, + video_url="https://example.com/secret.mp4", + status=LessonStatus.PUBLISHED, + is_preview=False, + order=1, + ) + r = self.client.get(f"/api/courses/{self.published.slug}/") + sections = r.json()["sections"] + lessons = sections[0]["lessons"] + free_payload = next(l for l in lessons if l["id"] == free.id) + locked_payload = next(l for l in lessons if l["id"] == locked.id) + self.assertEqual(free_payload["video_url"], "https://example.com/v.mp4") + self.assertTrue(free_payload["is_downloadable"]) + self.assertIsNone(locked_payload["video_url"]) + self.assertFalse(locked_payload["is_downloadable"]) + + def test_public_detail_includes_about_instructor_card(self): + # set bio + headline + verified + tp = self.t.teacher_profile + tp.bio = "10 years of UX design" + tp.headline = "Senior Product Designer" + tp.is_verified = True + tp.years_experience = 10 + tp.save() + r = self.client.get(f"/api/courses/{self.published.slug}/") + self.assertEqual(r.status_code, 200) + instr = r.json()["instructor"] + self.assertEqual(instr["id"], self.t.id) + self.assertEqual(instr["headline"], "Senior Product Designer") + self.assertEqual(instr["bio"], "10 years of UX design") + self.assertTrue(instr["is_verified"]) + self.assertEqual(instr["years_experience"], 10) + + +# --------------------------------------------------------------------------- instructor CRUD + +class InstructorCourseAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _make_teacher(phone="09120000001", name="Teacher One", approved=False) + self.t_approved = _make_teacher(phone="09120000002", name="Approved One", approved=True) + self.student = _make_student() + + def test_anon_blocked(self): + r = self.client.get("/api/instructor/courses/") + self.assertEqual(r.status_code, 401) + + def test_student_forbidden(self): + _auth(self.client, self.student) + r = self.client.get("/api/instructor/courses/") + self.assertEqual(r.status_code, 403) + + def test_teacher_creates_course(self): + _auth(self.client, self.t) + r = self.client.post( + "/api/instructor/courses/", + {"title": "New", "tagline": "T", "description": "D", "level": "beginner", "language": "fa"}, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + self.assertEqual(Course.objects.filter(instructor=self.t).count(), 1) + + def test_teacher_only_lists_own_courses(self): + a = _course(self.t, title="Mine") + b = _course(self.t_approved, title="Theirs") + _auth(self.client, self.t) + r = self.client.get("/api/instructor/courses/") + self.assertEqual(r.status_code, 200) + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(a.id, ids) + self.assertNotIn(b.id, ids) + + def test_publish_requires_approved_profile(self): + c = _course(self.t) # not approved + _auth(self.client, self.t) + r = self.client.post(f"/api/instructor/courses/{c.id}/publish/") + self.assertEqual(r.status_code, 403) + c.refresh_from_db() + self.assertEqual(c.status, CourseStatus.DRAFT) + + def test_publish_succeeds_when_approved(self): + c = _course(self.t_approved) + _auth(self.client, self.t_approved) + r = self.client.post(f"/api/instructor/courses/{c.id}/publish/") + self.assertEqual(r.status_code, 200, r.content) + c.refresh_from_db() + self.assertEqual(c.status, CourseStatus.PUBLISHED) + + def test_unpublish(self): + c = _course(self.t_approved) + c.publish() + _auth(self.client, self.t_approved) + r = self.client.post(f"/api/instructor/courses/{c.id}/unpublish/") + self.assertEqual(r.status_code, 200) + c.refresh_from_db() + self.assertEqual(c.status, CourseStatus.DRAFT) + + def test_cannot_modify_others_course(self): + c = _course(self.t_approved, title="His") + _auth(self.client, self.t) + r = self.client.patch( + f"/api/instructor/courses/{c.id}/", {"title": "Hijacked"}, format="json" + ) + # not visible in queryset → 404 + self.assertIn(r.status_code, (403, 404)) + + +# --------------------------------------------------------------------------- section + lesson + +class InstructorSectionLessonAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _make_teacher(approved=True) + self.course = _course(self.t) + _auth(self.client, self.t) + + def test_create_section_in_owned_course(self): + r = self.client.post( + "/api/instructor/sections/", + {"course": self.course.id, "title": "S1", "order": 0}, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + + def test_cannot_create_section_in_others_course(self): + other = _make_teacher(phone="09120000050", approved=True) + other_course = _course(other) + r = self.client.post( + "/api/instructor/sections/", + {"course": other_course.id, "title": "S1", "order": 0}, + format="json", + ) + self.assertEqual(r.status_code, 403) + + def test_create_video_lesson_validates_video_url(self): + sec = Section.objects.create(course=self.course, title="S1") + r = self.client.post( + "/api/instructor/lessons/", + {"section": sec.id, "title": "L1", "kind": "video", "order": 0}, + format="json", + ) + self.assertEqual(r.status_code, 400) + self.assertIn("video_url", r.json()) + + def test_create_text_lesson_requires_body(self): + sec = Section.objects.create(course=self.course, title="S1") + r = self.client.post( + "/api/instructor/lessons/", + {"section": sec.id, "title": "L1", "kind": "text", "order": 0}, + format="json", + ) + self.assertEqual(r.status_code, 400) + self.assertIn("body", r.json()) + + def test_create_video_lesson_ok(self): + sec = Section.objects.create(course=self.course, title="S1") + r = self.client.post( + "/api/instructor/lessons/", + { + "section": sec.id, + "title": "L1", + "kind": "video", + "video_url": "https://example.com/v.mp4", + "order": 0, + }, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + + def test_reorder_sections(self): + s1 = Section.objects.create(course=self.course, title="A", order=0) + s2 = Section.objects.create(course=self.course, title="B", order=1) + s3 = Section.objects.create(course=self.course, title="C", order=2) + r = self.client.post( + f"/api/instructor/courses/{self.course.id}/sections/reorder/", + {"ids": [s3.id, s1.id, s2.id]}, + format="json", + ) + self.assertEqual(r.status_code, 204, r.content) + s3.refresh_from_db(); s1.refresh_from_db(); s2.refresh_from_db() + self.assertEqual(s3.order, 0) + self.assertEqual(s1.order, 1) + self.assertEqual(s2.order, 2) + + def test_reorder_rejects_foreign_section_ids(self): + s1 = Section.objects.create(course=self.course, title="A", order=0) + other = _make_teacher(phone="09120000050", approved=True) + other_course = _course(other) + foreign = Section.objects.create(course=other_course, title="X", order=0) + r = self.client.post( + f"/api/instructor/courses/{self.course.id}/sections/reorder/", + {"ids": [s1.id, foreign.id]}, + format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_reorder_lessons(self): + sec = Section.objects.create(course=self.course, title="S1") + l1 = Lesson.objects.create( + section=sec, title="A", kind=LessonKind.VIDEO, + video_url="https://e.com/a", order=0, + ) + l2 = Lesson.objects.create( + section=sec, title="B", kind=LessonKind.VIDEO, + video_url="https://e.com/b", order=1, + ) + l3 = Lesson.objects.create( + section=sec, title="C", kind=LessonKind.VIDEO, + video_url="https://e.com/c", order=2, + ) + r = self.client.post( + f"/api/instructor/sections/{sec.id}/lessons/reorder/", + {"ids": [l3.id, l1.id, l2.id]}, + format="json", + ) + self.assertEqual(r.status_code, 204, r.content) + l1.refresh_from_db(); l2.refresh_from_db(); l3.refresh_from_db() + self.assertEqual([l3.order, l1.order, l2.order], [0, 1, 2]) + + +# --------------------------------------------------------------------------- highlights + FAQs + +class HighlightFAQAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _make_teacher(approved=True) + self.course = _course(self.t) + _auth(self.client, self.t) + + def test_create_highlight(self): + r = self.client.post( + "/api/instructor/highlights/", + {"course": self.course.id, "text": "First highlight", "order": 0}, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + self.assertEqual(self.course.highlights.count(), 1) + + def test_cannot_create_highlight_in_others_course(self): + other = _make_teacher(phone="09120000050", approved=True) + other_course = _course(other) + r = self.client.post( + "/api/instructor/highlights/", + {"course": other_course.id, "text": "Stolen", "order": 0}, + format="json", + ) + self.assertEqual(r.status_code, 403) + + def test_highlight_list_filtered_by_course(self): + other_course = _course(self.t, title="Other") + a = CourseHighlight.objects.create(course=self.course, text="X", order=0) + b = CourseHighlight.objects.create(course=other_course, text="Y", order=0) + r = self.client.get(f"/api/instructor/highlights/?course={self.course.id}") + self.assertEqual(r.status_code, 200) + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(a.id, ids) + self.assertNotIn(b.id, ids) + + def test_create_faq(self): + r = self.client.post( + "/api/instructor/faqs/", + {"course": self.course.id, "question": "Q?", "answer": "A.", "order": 0}, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + self.assertEqual(self.course.faqs.count(), 1) + + def test_faq_appears_in_public_detail(self): + self.course.publish() + CourseFAQ.objects.create(course=self.course, question="Q", answer="A", order=0) + unauth = APIClient() + r = unauth.get(f"/api/courses/{self.course.slug}/") + self.assertEqual(r.status_code, 200) + faqs = r.json()["faqs"] + self.assertEqual(len(faqs), 1) + self.assertEqual(faqs[0]["question"], "Q") + + +# --------------------------------------------------------------------------- instructor filters + +class InstructorFilterTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _make_teacher(approved=True) + _auth(self.client, self.t) + + self.draft = _course(self.t, title="Draft One") + self.pub = _course(self.t, title="Pub One") + self.pub.publish() + + def test_instructor_courses_filter_by_status(self): + r = self.client.get("/api/instructor/courses/?status=published") + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(self.pub.id, ids) + self.assertNotIn(self.draft.id, ids) + + r = self.client.get("/api/instructor/courses/?status=draft") + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(self.draft.id, ids) + self.assertNotIn(self.pub.id, ids) + + def test_instructor_sections_filter_by_course(self): + s_a = Section.objects.create(course=self.draft, title="A") + s_b = Section.objects.create(course=self.pub, title="B") + r = self.client.get(f"/api/instructor/sections/?course={self.draft.id}") + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(s_a.id, ids) + self.assertNotIn(s_b.id, ids) + + def test_instructor_lessons_filter_by_section(self): + sec_a = Section.objects.create(course=self.draft, title="A") + sec_b = Section.objects.create(course=self.draft, title="B") + la = Lesson.objects.create( + section=sec_a, title="LA", kind=LessonKind.VIDEO, + video_url="https://e.com/la", order=0, + ) + lb = Lesson.objects.create( + section=sec_b, title="LB", kind=LessonKind.VIDEO, + video_url="https://e.com/lb", order=0, + ) + r = self.client.get(f"/api/instructor/lessons/?section={sec_a.id}") + ids = [row["id"] for row in r.json()["results"]] + self.assertIn(la.id, ids) + self.assertNotIn(lb.id, ids) diff --git a/apps/courses/translation.py b/apps/courses/translation.py new file mode 100644 index 0000000..f64b27e --- /dev/null +++ b/apps/courses/translation.py @@ -0,0 +1,33 @@ +from modeltranslation.translator import TranslationOptions, register + +from .models import Category, Course, CourseFAQ, CourseHighlight, Lesson, Section + + +@register(Category) +class CategoryTranslationOptions(TranslationOptions): + fields = ("name",) + + +@register(Course) +class CourseTranslationOptions(TranslationOptions): + fields = ("title", "tagline", "description") + + +@register(CourseHighlight) +class CourseHighlightTranslationOptions(TranslationOptions): + fields = ("text",) + + +@register(CourseFAQ) +class CourseFAQTranslationOptions(TranslationOptions): + fields = ("question", "answer") + + +@register(Section) +class SectionTranslationOptions(TranslationOptions): + fields = ("title",) + + +@register(Lesson) +class LessonTranslationOptions(TranslationOptions): + fields = ("title", "body") diff --git a/apps/enrollments/__init__.py b/apps/enrollments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/enrollments/admin.py b/apps/enrollments/admin.py new file mode 100644 index 0000000..54da63b --- /dev/null +++ b/apps/enrollments/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin + +from .models import ActivityEvent, Enrollment, LessonProgress + + +@admin.register(Enrollment) +class EnrollmentAdmin(admin.ModelAdmin): + list_display = ("student", "course", "enrolled_at", "completed_at") + list_filter = ("course",) + search_fields = ("student__phone_number", "student__full_name", "course__slug") + raw_id_fields = ("student", "course") + readonly_fields = ("enrolled_at",) + + +@admin.register(LessonProgress) +class LessonProgressAdmin(admin.ModelAdmin): + list_display = ("enrollment", "lesson", "completed_at") + list_filter = ("completed_at",) + raw_id_fields = ("enrollment", "lesson") + + +@admin.register(ActivityEvent) +class ActivityEventAdmin(admin.ModelAdmin): + list_display = ("kind", "course", "actor", "created_at") + list_filter = ("kind", "course") + search_fields = ("course__slug", "actor__full_name", "actor__phone_number") + raw_id_fields = ("course", "actor") + readonly_fields = ("created_at",) + date_hierarchy = "created_at" diff --git a/apps/enrollments/api/__init__.py b/apps/enrollments/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/enrollments/api/analytics.py b/apps/enrollments/api/analytics.py new file mode 100644 index 0000000..259db17 --- /dev/null +++ b/apps/enrollments/api/analytics.py @@ -0,0 +1,324 @@ +"""Instructor analytics endpoints — KPIs, monthly revenue chart, activity feed.""" + +from datetime import timedelta +from decimal import Decimal +from typing import Optional + +from django.db.models import Avg, Count, F, FloatField, IntegerField, OuterRef, Q, Subquery, Sum +from django.db.models.functions import Coalesce, TruncMonth +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import ListAPIView +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from django.utils import timezone + +from apps.accounts.models import Role, User +from apps.courses.models import Course, Lesson, LessonStatus +from apps.enrollments.models import ActivityEvent, Enrollment, LessonProgress +from apps.payments.models import Order, OrderStatus + + +# --------------------------------------------------------------------------- helpers + +def _ensure_teacher(request): + if request.user.role != Role.TEACHER: + raise PermissionDenied("Only teachers can access this analytics view.") + + +def _instructor_courses(user): + return Course.objects.filter(instructor=user) + + +def _start_of_month(dt): + return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _percent_delta(current: int, previous: int) -> Optional[int]: + if previous == 0: + return None + return int(round((current - previous) * 100 / previous)) + + +# --------------------------------------------------------------------------- KPIs + + +class KPISparkSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField() + value = serializers.IntegerField() + unit = serializers.CharField(allow_null=True) + delta_percent = serializers.IntegerField(allow_null=True) + good = serializers.BooleanField(allow_null=True) + spark = serializers.ListField(child=serializers.IntegerField()) + + +class InstructorKPIsView(APIView): + """Four KPI cards for the instructor home: revenue, active students, avg progress, satisfaction.""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["analytics"], + summary="Instructor KPI snapshot (4 cards)", + description=( + "Returns the four KPI cards shown on the instructor home, in the design's shape: " + "revenue this month (Toman, instructor share), active students (last 7 days), " + "average progress %, satisfaction (review average — null until reviews ship). " + "Each card includes a `delta_percent` vs the previous period and a 12-point sparkline." + ), + responses={200: KPISparkSerializer(many=True)}, + ) + def get(self, request): + _ensure_teacher(request) + user = request.user + now = timezone.now() + this_month_start = _start_of_month(now) + prev_month_start = _start_of_month(this_month_start - timedelta(days=1)) + seven_days_ago = now - timedelta(days=7) + prev_seven_days_ago = now - timedelta(days=14) + + # ---- Revenue (this month vs last month) ----- + rev_q = Order.objects.filter( + course__instructor=user, status=OrderStatus.PAID, paid_at__isnull=False + ) + revenue_this = rev_q.filter(paid_at__gte=this_month_start).aggregate( + t=Coalesce(Sum("instructor_share_toman"), 0) + )["t"] + revenue_prev = rev_q.filter( + paid_at__gte=prev_month_start, paid_at__lt=this_month_start + ).aggregate(t=Coalesce(Sum("instructor_share_toman"), 0))["t"] + + # 12-month sparkline + spark_revenue = self._monthly_spark( + queryset=rev_q, + month_field="paid_at", + agg=Sum("instructor_share_toman"), + ) + + # ---- Active students ----- + active_now = User.objects.filter( + role=Role.STUDENT, + last_active_at__gte=seven_days_ago, + enrollments__course__instructor=user, + ).distinct().count() + active_prev = User.objects.filter( + role=Role.STUDENT, + last_active_at__gte=prev_seven_days_ago, + last_active_at__lt=seven_days_ago, + enrollments__course__instructor=user, + ).distinct().count() + + # Sparkline: enrollments per month over the last 12 months (proxy for "students" growth) + spark_students = self._monthly_spark( + queryset=Enrollment.objects.filter(course__instructor=user), + month_field="enrolled_at", + agg=Count("id"), + ) + + # ---- Average progress % ----- + enrollments = list( + Enrollment.objects.filter(course__instructor=user).select_related("course") + ) + if enrollments: + avg_progress = int(round(sum(e.progress_percent() for e in enrollments) / len(enrollments))) + else: + avg_progress = 0 + + spark_progress = [avg_progress] * 12 # no historical snapshot model yet — flat line + + # ---- Satisfaction (placeholder until Reviews ship) ----- + satisfaction_value = 0 + satisfaction_unit = None + spark_satisfaction = [0] * 12 + + kpis = [ + { + "id": "revenue", + "label": "Revenue this month", + "value": int(revenue_this or 0), + "unit": "Toman", + "delta_percent": _percent_delta(revenue_this, revenue_prev), + "good": (revenue_this or 0) >= (revenue_prev or 0), + "spark": spark_revenue, + }, + { + "id": "active_students", + "label": "Active students (7d)", + "value": active_now, + "unit": "students", + "delta_percent": _percent_delta(active_now, active_prev), + "good": active_now >= active_prev, + "spark": spark_students, + }, + { + "id": "avg_progress", + "label": "Average progress", + "value": avg_progress, + "unit": "%", + "delta_percent": None, + "good": None, + "spark": spark_progress, + }, + { + "id": "satisfaction", + "label": "Student satisfaction", + "value": satisfaction_value, + "unit": satisfaction_unit, + "delta_percent": None, + "good": None, + "spark": spark_satisfaction, + }, + ] + return Response(KPISparkSerializer(kpis, many=True).data) + + @staticmethod + def _monthly_spark(*, queryset, month_field: str, agg) -> list[int]: + """Return a 12-element list of monthly aggregate values, oldest-to-newest.""" + now = timezone.now() + first_month = _start_of_month(now) - timedelta(days=365) # roughly 12 months back + first_month = _start_of_month(first_month + timedelta(days=2)) + rows = ( + queryset.filter(**{f"{month_field}__gte": first_month}) + .annotate(_m=TruncMonth(month_field)) + .values("_m") + .annotate(v=Coalesce(agg, 0)) + .order_by("_m") + ) + bucket = {row["_m"].replace(day=1).date(): int(row["v"] or 0) for row in rows} + out = [] + cursor = _start_of_month(now) + # Build oldest → newest, 12 entries + months = [] + for i in range(11, -1, -1): + month_start = _start_of_month(cursor - timedelta(days=30 * i)) + months.append(month_start.date().replace(day=1)) + for m in months: + out.append(bucket.get(m, 0)) + return out + + +# --------------------------------------------------------------------------- Monthly revenue + + +class MonthlyRevenueSerializer(serializers.Serializer): + month = serializers.DateField() + label = serializers.CharField() + revenue_toman = serializers.IntegerField() + orders_count = serializers.IntegerField() + + +class InstructorMonthlyRevenueView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["analytics"], + summary="Monthly revenue chart", + description="Returns the last 12 calendar months of revenue (instructor share) and order count.", + responses={200: MonthlyRevenueSerializer(many=True)}, + ) + def get(self, request): + _ensure_teacher(request) + now = timezone.now() + # Build the 12 month buckets oldest → newest + cursor = _start_of_month(now) + months = [] + for i in range(11, -1, -1): + month_start = _start_of_month(cursor - timedelta(days=30 * i)) + months.append(month_start.replace(day=1)) + + rows = ( + Order.objects.filter( + course__instructor=request.user, + status=OrderStatus.PAID, + paid_at__gte=months[0], + ) + .annotate(_m=TruncMonth("paid_at")) + .values("_m") + .annotate( + rev=Coalesce(Sum("instructor_share_toman"), 0), + cnt=Count("id"), + ) + ) + bucket = {row["_m"].replace(day=1).date(): row for row in rows} + + out = [] + for m in months: + key = m.date().replace(day=1) + row = bucket.get(key) + out.append({ + "month": key, + "label": key.strftime("%Y-%m"), + "revenue_toman": int(row["rev"] if row else 0), + "orders_count": int(row["cnt"] if row else 0), + }) + return Response(MonthlyRevenueSerializer(out, many=True).data) + + +# --------------------------------------------------------------------------- Activity feed + + +class ActivityEventSerializer(serializers.ModelSerializer): + actor_name = serializers.CharField(source="actor.full_name", read_only=True, allow_null=True) + course_slug = serializers.CharField(source="course.slug", read_only=True) + course_title = serializers.CharField(source="course.title", read_only=True) + + class Meta: + model = ActivityEvent + fields = [ + "id", + "kind", + "course", + "course_slug", + "course_title", + "actor", + "actor_name", + "payload", + "created_at", + ] + read_only_fields = fields + + +class _ActivityPagination(PageNumberPagination): + page_size = 30 + page_size_query_param = "page_size" + max_page_size = 100 + + +class InstructorActivityFeedView(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ActivityEventSerializer + pagination_class = _ActivityPagination + + @extend_schema( + tags=["analytics"], + summary="Activity feed (instructor home)", + description=( + "Most recent events across the instructor's courses. Filter by `?course=` or " + "`?kind=enrolled|paid|lesson_completed|course_completed|announcement_posted`." + ), + parameters=[ + OpenApiParameter(name="course", type=int, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="kind", type=str, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request, *args, **kwargs): + _ensure_teacher(request) + return super().get(request, *args, **kwargs) + + def get_queryset(self): + qs = ( + ActivityEvent.objects + .filter(course__instructor=self.request.user) + .select_related("course", "actor") + ) + course_id = self.request.query_params.get("course") + if course_id: + qs = qs.filter(course_id=course_id) + kind = self.request.query_params.get("kind") + if kind: + qs = qs.filter(kind=kind) + return qs diff --git a/apps/enrollments/api/permissions.py b/apps/enrollments/api/permissions.py new file mode 100644 index 0000000..a89572b --- /dev/null +++ b/apps/enrollments/api/permissions.py @@ -0,0 +1,11 @@ +from rest_framework.permissions import BasePermission + +from apps.accounts.models import Role + + +class IsStudent(BasePermission): + message = "Only students can perform this action." + + def has_permission(self, request, view): + u = request.user + return bool(u and u.is_authenticated and u.role == Role.STUDENT) diff --git a/apps/enrollments/api/serializers.py b/apps/enrollments/api/serializers.py new file mode 100644 index 0000000..a14a6f1 --- /dev/null +++ b/apps/enrollments/api/serializers.py @@ -0,0 +1,172 @@ +from rest_framework import serializers + +from apps.courses.api.serializers import ( + CategorySerializer, + CourseFAQSerializer, + CourseHighlightSerializer, + LessonReadSerializer, + SectionReadSerializer, +) +from apps.courses.models import Lesson, LessonStatus +from apps.enrollments.models import Enrollment, LessonProgress + + +class EnrolledCourseSummarySerializer(serializers.Serializer): + """Used by the my-courses dashboard list. Mirrors the design's MY_COURSES shape.""" + + id = serializers.IntegerField() + slug = serializers.CharField() + title = serializers.CharField() + tagline = serializers.CharField() + color = serializers.CharField() + cover_image = serializers.SerializerMethodField() + instructor_id = serializers.IntegerField() + instructor_name = serializers.SerializerMethodField() + enrolled_at = serializers.DateTimeField() + completed_at = serializers.DateTimeField(allow_null=True) + progress_percent = serializers.IntegerField() + completed_lessons = serializers.IntegerField() + total_lessons = serializers.IntegerField() + last_lesson_id = serializers.IntegerField(allow_null=True) + last_lesson_title = serializers.CharField(allow_null=True) + next_lesson_id = serializers.IntegerField(allow_null=True) + next_lesson_title = serializers.CharField(allow_null=True) + + def get_cover_image(self, obj): + url = obj.get("cover_image_url") + request = self.context.get("request") + if url and request: + return request.build_absolute_uri(url) + return url + + def get_instructor_name(self, obj): + return obj.get("instructor_name") + + +class StudentCourseDetailSerializer(serializers.Serializer): + """Full course detail for an enrolled student — all published lesson content unlocked.""" + + id = serializers.IntegerField() + slug = serializers.CharField() + title = serializers.CharField() + tagline = serializers.CharField() + description = serializers.CharField() + level = serializers.CharField() + language = serializers.CharField() + color = serializers.CharField() + cover_image = serializers.SerializerMethodField() + instructor_id = serializers.IntegerField() + instructor_name = serializers.CharField() + category = CategorySerializer(allow_null=True) + highlights = CourseHighlightSerializer(many=True) + faqs = CourseFAQSerializer(many=True) + sections = serializers.SerializerMethodField() + enrollment = serializers.SerializerMethodField() + + def get_cover_image(self, obj): + course = obj["course"] + if not course.cover_image: + return None + request = self.context.get("request") + if request: + return request.build_absolute_uri(course.cover_image.url) + return course.cover_image.url + + def get_sections(self, obj): + course = obj["course"] + completed_lesson_ids = obj["completed_lesson_ids"] + sections = course.sections.all().prefetch_related( + "lessons" + ) + out = [] + for sec in sections: + published = [l for l in sec.lessons.all() if l.status == LessonStatus.PUBLISHED] + published.sort(key=lambda l: (l.order, l.id)) + out.append({ + "id": sec.id, + "title": sec.title, + "order": sec.order, + "lessons": LessonReadSerializer( + published, many=True + ).data, + "lessons_completed": [ + l.id for l in published if l.id in completed_lesson_ids + ], + }) + return out + + def get_enrollment(self, obj): + e = obj["enrollment"] + return { + "enrolled_at": e.enrolled_at, + "completed_at": e.completed_at, + "progress_percent": e.progress_percent(), + "completed_lessons": e.completed_lessons_count(), + "total_lessons": e.total_lessons_count(), + } + + +class EnrollResponseSerializer(serializers.Serializer): + enrolled = serializers.BooleanField() + course_id = serializers.IntegerField() + course_slug = serializers.CharField() + enrolled_at = serializers.DateTimeField() + + +class LessonProgressResponseSerializer(serializers.ModelSerializer): + progress_percent = serializers.SerializerMethodField() + course_completed = serializers.SerializerMethodField() + + class Meta: + model = LessonProgress + fields = ["id", "lesson", "completed_at", "progress_percent", "course_completed"] + read_only_fields = fields + + def get_progress_percent(self, obj): + return obj.enrollment.progress_percent() + + def get_course_completed(self, obj): + return obj.enrollment.is_completed + + +class InstructorEnrollmentSerializer(serializers.ModelSerializer): + """Compact list view for the instructor's CRM-style student panel.""" + + student_id = serializers.IntegerField(source="student.id", read_only=True) + student_name = serializers.CharField(source="student.full_name", read_only=True) + student_phone = serializers.CharField(source="student.phone_number", read_only=True) + student_last_active_at = serializers.DateTimeField(source="student.last_active_at", read_only=True) + progress_percent = serializers.SerializerMethodField() + completed_lessons = serializers.SerializerMethodField() + total_lessons = serializers.SerializerMethodField() + engagement_status = serializers.SerializerMethodField() + + class Meta: + model = Enrollment + fields = [ + "id", + "student_id", + "student_name", + "student_phone", + "student_last_active_at", + "course_id", + "enrolled_at", + "completed_at", + "progress_percent", + "completed_lessons", + "total_lessons", + "engagement_status", + ] + read_only_fields = fields + + def get_progress_percent(self, obj): + return obj.progress_percent() + + def get_completed_lessons(self, obj): + return obj.completed_lessons_count() + + def get_total_lessons(self, obj): + return obj.total_lessons_count() + + def get_engagement_status(self, obj): + return obj.engagement_status() diff --git a/apps/enrollments/api/urls.py b/apps/enrollments/api/urls.py new file mode 100644 index 0000000..c0b5128 --- /dev/null +++ b/apps/enrollments/api/urls.py @@ -0,0 +1,30 @@ +from django.urls import path + +from .analytics import ( + InstructorActivityFeedView, + InstructorKPIsView, + InstructorMonthlyRevenueView, +) +from .views import ( + CourseEnrollmentsView, + EnrollView, + LessonCompleteView, + LessonUncompleteView, + MyCourseDetailView, + MyCoursesView, +) + +urlpatterns = [ + # student surface + path("courses//enroll/", EnrollView.as_view(), name="enroll"), + path("me/courses/", MyCoursesView.as_view(), name="my-courses"), + path("me/courses//", MyCourseDetailView.as_view(), name="my-course-detail"), + path("me/lessons//complete/", LessonCompleteView.as_view(), name="lesson-complete"), + path("me/lessons//uncomplete/", LessonUncompleteView.as_view(), name="lesson-uncomplete"), + # instructor surface (course-scoped) + path("instructor/courses//enrollments/", CourseEnrollmentsView.as_view(), name="instructor-course-enrollments"), + # instructor analytics + path("instructor/kpis/", InstructorKPIsView.as_view(), name="instructor-kpis"), + path("instructor/revenue/monthly/", InstructorMonthlyRevenueView.as_view(), name="instructor-monthly-revenue"), + path("instructor/activity/", InstructorActivityFeedView.as_view(), name="instructor-activity"), +] diff --git a/apps/enrollments/api/views.py b/apps/enrollments/api/views.py new file mode 100644 index 0000000..481cac0 --- /dev/null +++ b/apps/enrollments/api/views.py @@ -0,0 +1,307 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.courses.models import Course, CourseStatus, Lesson, LessonStatus +from apps.enrollments.models import ActivityKind, Enrollment, LessonProgress +from apps.enrollments.services.events import record_event + +from .permissions import IsStudent +from .serializers import ( + EnrolledCourseSummarySerializer, + EnrollResponseSerializer, + InstructorEnrollmentSerializer, + LessonProgressResponseSerializer, + StudentCourseDetailSerializer, +) + + +# --- helpers --------------------------------------------------------------------- + +def _get_or_404_published(slug: str) -> Course: + return get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED) + + +def _next_and_last(enrollment: Enrollment, completed_lesson_ids: set): + """Return (last_lesson, next_lesson) tuples (lesson_id, lesson_title) given a course's + published lessons in (section.order, lesson.order) sequence.""" + lessons = list( + Lesson.objects.filter( + section__course_id=enrollment.course_id, status=LessonStatus.PUBLISHED + ) + .select_related("section") + .order_by("section__order", "section__id", "order", "id") + ) + last = next_l = None + for lesson in lessons: + if lesson.id in completed_lesson_ids: + last = lesson + elif next_l is None: + next_l = lesson + return last, next_l + + +# --- student-facing ------------------------------------------------------------- + +class EnrollView(APIView): + permission_classes = [IsAuthenticated, IsStudent] + + @extend_schema( + tags=["enrollments"], + summary="Enroll in a published course (free only)", + description=( + "Free-course path. Creates an Enrollment idempotently. " + "If the course has a non-zero `price_toman`, returns 402 — caller must use " + "`POST /api/courses//checkout/` instead." + ), + request=None, + responses={ + 200: EnrollResponseSerializer, + 201: EnrollResponseSerializer, + 402: OpenApiResponse(description="Course is paid; use checkout."), + 403: OpenApiResponse(description="Not a student."), + 404: OpenApiResponse(description="Course not found or not published."), + }, + ) + def post(self, request, slug): + course = _get_or_404_published(slug) + if not course.is_free: + return Response( + { + "detail": "This course requires payment.", + "checkout_url": f"/api/courses/{course.slug}/checkout/", + "price_toman": course.price_toman, + }, + status=status.HTTP_402_PAYMENT_REQUIRED, + ) + enrollment, created = Enrollment.objects.get_or_create( + student=request.user, course=course + ) + if created: + record_event( + course=course, + actor=request.user, + kind=ActivityKind.ENROLLED.value, + payload={"price_toman": 0, "via": "free"}, + ) + body = { + "enrolled": True, + "course_id": course.id, + "course_slug": course.slug, + "enrolled_at": enrollment.enrolled_at, + } + return Response( + EnrollResponseSerializer(body).data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +class MyCoursesView(APIView): + permission_classes = [IsAuthenticated, IsStudent] + + @extend_schema( + tags=["enrollments"], + summary="List my enrolled courses", + description="Returns each enrolled course with progress %, completed/total lessons, last/next lesson — the shape the student dashboard expects.", + responses={200: EnrolledCourseSummarySerializer(many=True)}, + ) + def get(self, request): + qs = ( + Enrollment.objects + .filter(student=request.user) + .select_related("course", "course__instructor") + .order_by("-enrolled_at") + ) + results = [] + for e in qs: + completed_ids = set( + e.lesson_progress.filter(completed_at__isnull=False).values_list("lesson_id", flat=True) + ) + last, nxt = _next_and_last(e, completed_ids) + results.append({ + "id": e.course.id, + "slug": e.course.slug, + "title": e.course.title, + "tagline": e.course.tagline, + "color": e.course.color, + "cover_image_url": e.course.cover_image.url if e.course.cover_image else None, + "instructor_id": e.course.instructor_id, + "instructor_name": e.course.instructor.full_name, + "enrolled_at": e.enrolled_at, + "completed_at": e.completed_at, + "progress_percent": e.progress_percent(), + "completed_lessons": e.completed_lessons_count(), + "total_lessons": e.total_lessons_count(), + "last_lesson_id": last.id if last else None, + "last_lesson_title": last.title if last else None, + "next_lesson_id": nxt.id if nxt else None, + "next_lesson_title": nxt.title if nxt else None, + }) + ser = EnrolledCourseSummarySerializer(results, many=True, context={"request": request}) + return Response(ser.data) + + +class MyCourseDetailView(APIView): + permission_classes = [IsAuthenticated, IsStudent] + + @extend_schema( + tags=["enrollments"], + summary="My course detail (full content unlocked)", + description="Returns the full structure of an enrolled course with all published lesson content (no preview gating). 403 if not enrolled, 404 if course is not published.", + responses={ + 200: StudentCourseDetailSerializer, + 403: OpenApiResponse(description="Not enrolled."), + }, + ) + def get(self, request, slug): + course = _get_or_404_published(slug) + try: + enrollment = Enrollment.objects.get(student=request.user, course=course) + except Enrollment.DoesNotExist: + raise PermissionDenied("You are not enrolled in this course.") + + completed_lesson_ids = set( + enrollment.lesson_progress.filter(completed_at__isnull=False).values_list( + "lesson_id", flat=True + ) + ) + payload = { + "id": course.id, + "slug": course.slug, + "title": course.title, + "tagline": course.tagline, + "description": course.description, + "level": course.level, + "language": course.language, + "color": course.color, + "instructor_id": course.instructor_id, + "instructor_name": course.instructor.full_name, + "category": course.category, + "highlights": list(course.highlights.all()), + "faqs": list(course.faqs.all()), + "course": course, + "enrollment": enrollment, + "completed_lesson_ids": completed_lesson_ids, + } + ser = StudentCourseDetailSerializer(payload, context={"request": request}) + return Response(ser.data) + + +class _LessonProgressBase(APIView): + permission_classes = [IsAuthenticated, IsStudent] + + def _get_enrollment_and_lesson(self, request, lesson_id): + lesson = get_object_or_404(Lesson, pk=lesson_id, status=LessonStatus.PUBLISHED) + try: + enrollment = Enrollment.objects.get( + student=request.user, course=lesson.section.course + ) + except Enrollment.DoesNotExist: + raise PermissionDenied("You are not enrolled in this course.") + return enrollment, lesson + + +class LessonCompleteView(_LessonProgressBase): + @extend_schema( + tags=["enrollments"], + summary="Mark a lesson complete", + description="Marks the lesson complete for the current student. Idempotent. Auto-marks the enrollment as completed when all published lessons are done.", + request=None, + responses={200: LessonProgressResponseSerializer}, + ) + def post(self, request, lesson_id): + from django.utils import timezone + + enrollment, lesson = self._get_enrollment_and_lesson(request, lesson_id) + course_just_completed = False + with transaction.atomic(): + progress, _ = LessonProgress.objects.get_or_create( + enrollment=enrollment, lesson=lesson + ) + newly_completed = progress.completed_at is None + if newly_completed: + progress.completed_at = timezone.now() + progress.save(update_fields=["completed_at"]) + was_completed_before = enrollment.completed_at is not None + enrollment.maybe_mark_completed() + course_just_completed = (not was_completed_before) and enrollment.completed_at is not None + if newly_completed: + record_event( + course=enrollment.course, + actor=request.user, + kind=ActivityKind.LESSON_COMPLETED.value, + payload={"lesson_id": lesson.id, "lesson_title": lesson.title}, + ) + if course_just_completed: + record_event( + course=enrollment.course, + actor=request.user, + kind=ActivityKind.COURSE_COMPLETED.value, + payload={}, + ) + return Response(LessonProgressResponseSerializer(progress).data) + + +class LessonUncompleteView(_LessonProgressBase): + @extend_schema( + tags=["enrollments"], + summary="Un-mark a lesson complete", + description="Reverses a previous mark-complete. Also clears `enrollment.completed_at` if the course was previously fully completed.", + request=None, + responses={ + 200: LessonProgressResponseSerializer, + 404: OpenApiResponse(description="No progress record exists for this lesson."), + }, + ) + def post(self, request, lesson_id): + enrollment, lesson = self._get_enrollment_and_lesson(request, lesson_id) + try: + progress = LessonProgress.objects.get(enrollment=enrollment, lesson=lesson) + except LessonProgress.DoesNotExist: + return Response({"detail": "No progress yet."}, status=status.HTTP_404_NOT_FOUND) + if progress.completed_at is not None: + progress.completed_at = None + progress.save(update_fields=["completed_at"]) + enrollment.maybe_unmark_completed() + return Response(LessonProgressResponseSerializer(progress).data) + + +# --- instructor surface --------------------------------------------------------- + +class CourseEnrollmentsView(APIView): + """Instructor view of the students enrolled in one of their courses.""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["enrollments"], + summary="List students enrolled in my course", + description=( + "Instructor-only. Owner of the course only. " + "Filter `?engagement_status=active|at_risk|inactive|completed` to scope the CRM table." + ), + responses={ + 200: InstructorEnrollmentSerializer(many=True), + 403: OpenApiResponse(description="Not the course owner."), + }, + ) + def get(self, request, course_id): + course = get_object_or_404(Course, pk=course_id) + if course.instructor_id != request.user.id: + raise PermissionDenied("You don't own this course.") + qs = ( + Enrollment.objects + .filter(course=course) + .select_related("student") + .order_by("-enrolled_at") + ) + engagement_filter = request.query_params.get("engagement_status") + rows = InstructorEnrollmentSerializer(qs, many=True).data + if engagement_filter: + rows = [r for r in rows if r["engagement_status"] == engagement_filter] + return Response(rows) diff --git a/apps/enrollments/apps.py b/apps/enrollments/apps.py new file mode 100644 index 0000000..e0cc631 --- /dev/null +++ b/apps/enrollments/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class EnrollmentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.enrollments" + label = "enrollments" diff --git a/apps/enrollments/migrations/0001_initial.py b/apps/enrollments/migrations/0001_initial.py new file mode 100644 index 0000000..905d5d5 --- /dev/null +++ b/apps/enrollments/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 5.1.15 on 2026-05-02 15:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('courses', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Enrollment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enrolled_at', models.DateTimeField(auto_now_add=True, verbose_name='enrolled at')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='courses.course')), + ('student', models.ForeignKey(limit_choices_to={'role': 'student'}, on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'enrollment', + 'verbose_name_plural': 'enrollments', + 'ordering': ['-enrolled_at'], + }, + ), + migrations.CreateModel( + name='LessonProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')), + ('enrollment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_progress', to='enrollments.enrollment')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_records', to='courses.lesson')), + ], + options={ + 'verbose_name': 'lesson progress', + 'verbose_name_plural': 'lesson progress', + }, + ), + migrations.AddIndex( + model_name='enrollment', + index=models.Index(fields=['student', '-enrolled_at'], name='enrollments_student_dfac97_idx'), + ), + migrations.AddIndex( + model_name='enrollment', + index=models.Index(fields=['course', '-enrolled_at'], name='enrollments_course__c1239d_idx'), + ), + migrations.AddConstraint( + model_name='enrollment', + constraint=models.UniqueConstraint(fields=('student', 'course'), name='unique_enrollment_per_student_course'), + ), + migrations.AddIndex( + model_name='lessonprogress', + index=models.Index(fields=['enrollment', 'lesson'], name='enrollments_enrollm_a65958_idx'), + ), + migrations.AddConstraint( + model_name='lessonprogress', + constraint=models.UniqueConstraint(fields=('enrollment', 'lesson'), name='unique_progress_per_enrollment_lesson'), + ), + ] diff --git a/apps/enrollments/migrations/0002_activityevent.py b/apps/enrollments/migrations/0002_activityevent.py new file mode 100644 index 0000000..18aac31 --- /dev/null +++ b/apps/enrollments/migrations/0002_activityevent.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.15 on 2026-05-02 16:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0003_course_is_coming_soon_lesson_is_downloadable'), + ('enrollments', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ActivityEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('enrolled', 'Enrolled'), ('paid', 'Order paid'), ('lesson_completed', 'Lesson completed'), ('course_completed', 'Course completed'), ('announcement_posted', 'Announcement posted')], max_length=32, verbose_name='kind')), + ('payload', models.JSONField(blank=True, default=dict, verbose_name='payload')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_events', to=settings.AUTH_USER_MODEL)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_events', to='courses.course')), + ], + options={ + 'verbose_name': 'activity event', + 'verbose_name_plural': 'activity events', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['course', '-created_at'], name='enrollments_course__73d051_idx'), models.Index(fields=['actor', '-created_at'], name='enrollments_actor_i_34ace1_idx'), models.Index(fields=['kind', '-created_at'], name='enrollments_kind_b92b1b_idx')], + }, + ), + ] diff --git a/apps/enrollments/migrations/__init__.py b/apps/enrollments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/enrollments/models.py b/apps/enrollments/models.py new file mode 100644 index 0000000..bed328a --- /dev/null +++ b/apps/enrollments/models.py @@ -0,0 +1,161 @@ +from datetime import timedelta + +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class EngagementStatus(models.TextChoices): + ACTIVE = "active", _("Active") + AT_RISK = "at_risk", _("At risk") + INACTIVE = "inactive", _("Inactive") + COMPLETED = "completed", _("Completed") + + +class Enrollment(models.Model): + student = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="enrollments", + limit_choices_to={"role": "student"}, + ) + course = models.ForeignKey( + "courses.Course", + on_delete=models.CASCADE, + related_name="enrollments", + ) + enrolled_at = models.DateTimeField(_("enrolled at"), auto_now_add=True) + completed_at = models.DateTimeField(_("completed at"), null=True, blank=True) + + class Meta: + verbose_name = _("enrollment") + verbose_name_plural = _("enrollments") + ordering = ["-enrolled_at"] + constraints = [ + models.UniqueConstraint(fields=["student", "course"], name="unique_enrollment_per_student_course"), + ] + indexes = [ + models.Index(fields=["student", "-enrolled_at"]), + models.Index(fields=["course", "-enrolled_at"]), + ] + + def __str__(self): + return f"{self.student_id} → {self.course_id}" + + @property + def is_completed(self) -> bool: + return self.completed_at is not None + + def total_lessons_count(self) -> int: + from apps.courses.models import Lesson, LessonStatus + + return Lesson.objects.filter( + section__course_id=self.course_id, status=LessonStatus.PUBLISHED + ).count() + + def completed_lessons_count(self) -> int: + return self.lesson_progress.filter(completed_at__isnull=False).count() + + def progress_percent(self) -> int: + total = self.total_lessons_count() + if total == 0: + return 0 + return int(round(self.completed_lessons_count() * 100 / total)) + + def maybe_mark_completed(self): + total = self.total_lessons_count() + if total > 0 and self.completed_lessons_count() >= total and self.completed_at is None: + self.completed_at = timezone.now() + self.save(update_fields=["completed_at"]) + + def maybe_unmark_completed(self): + if self.completed_at is not None and self.completed_lessons_count() < self.total_lessons_count(): + self.completed_at = None + self.save(update_fields=["completed_at"]) + + def engagement_status(self) -> str: + """Compute engagement bucket based on completion + student's last activity. + + Buckets: + - completed → enrollment.completed_at is set + - active → student active within last 7 days and enrollment isn't completed + - at_risk → student inactive 7-14 days OR low progress (<20%) and inactive >7 days + - inactive → student last active >14 days ago + Falls back to `active` for brand-new enrollments where last_active_at is null. + """ + if self.completed_at is not None: + return EngagementStatus.COMPLETED.value + last = getattr(self.student, "last_active_at", None) + if last is None: + return EngagementStatus.ACTIVE.value + delta = timezone.now() - last + if delta < timedelta(days=7): + return EngagementStatus.ACTIVE.value + if delta < timedelta(days=14): + return EngagementStatus.AT_RISK.value + return EngagementStatus.INACTIVE.value + + +# --------------------------------------------------------------------------- +# Activity events — power the instructor's home activity feed and student +# detail timelines. Recorded explicitly via `services.events.record_event`. + +class ActivityKind(models.TextChoices): + ENROLLED = "enrolled", _("Enrolled") + PAID = "paid", _("Order paid") + LESSON_COMPLETED = "lesson_completed", _("Lesson completed") + COURSE_COMPLETED = "course_completed", _("Course completed") + ANNOUNCEMENT_POSTED = "announcement_posted", _("Announcement posted") + + +class ActivityEvent(models.Model): + course = models.ForeignKey( + "courses.Course", on_delete=models.CASCADE, related_name="activity_events" + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, blank=True, + related_name="activity_events", + ) + kind = models.CharField(_("kind"), max_length=32, choices=ActivityKind.choices) + payload = models.JSONField(_("payload"), default=dict, blank=True) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + + class Meta: + verbose_name = _("activity event") + verbose_name_plural = _("activity events") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["course", "-created_at"]), + models.Index(fields=["actor", "-created_at"]), + models.Index(fields=["kind", "-created_at"]), + ] + + def __str__(self): + return f"{self.kind}@{self.course_id}#{self.pk}" + + +class LessonProgress(models.Model): + enrollment = models.ForeignKey( + Enrollment, on_delete=models.CASCADE, related_name="lesson_progress" + ) + lesson = models.ForeignKey( + "courses.Lesson", on_delete=models.CASCADE, related_name="progress_records" + ) + completed_at = models.DateTimeField(_("completed at"), null=True, blank=True) + + class Meta: + verbose_name = _("lesson progress") + verbose_name_plural = _("lesson progress") + constraints = [ + models.UniqueConstraint( + fields=["enrollment", "lesson"], + name="unique_progress_per_enrollment_lesson", + ), + ] + indexes = [models.Index(fields=["enrollment", "lesson"])] + + def __str__(self): + return f"progress<{self.enrollment_id}:{self.lesson_id}>" diff --git a/apps/enrollments/services/__init__.py b/apps/enrollments/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/enrollments/services/events.py b/apps/enrollments/services/events.py new file mode 100644 index 0000000..bef14aa --- /dev/null +++ b/apps/enrollments/services/events.py @@ -0,0 +1,17 @@ +"""Helper for recording activity events from views/services.""" + +from __future__ import annotations + +from typing import Optional + +from apps.enrollments.models import ActivityEvent, ActivityKind + + +def record_event(*, course, actor, kind: str, payload: Optional[dict] = None) -> ActivityEvent: + """Create an ActivityEvent. `kind` must be one of ActivityKind.values.""" + return ActivityEvent.objects.create( + course=course, + actor=actor, + kind=kind, + payload=payload or {}, + ) diff --git a/apps/enrollments/tests.py b/apps/enrollments/tests.py new file mode 100644 index 0000000..eafbaf0 --- /dev/null +++ b/apps/enrollments/tests.py @@ -0,0 +1,524 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from datetime import timedelta + +from django.utils import timezone + +from apps.accounts.models import Role, TeacherProfile +from apps.announcements.models import Announcement +from apps.courses.models import ( + Course, + CourseStatus, + Lesson, + LessonKind, + LessonStatus, + Section, +) +from apps.enrollments.models import ( + ActivityEvent, + ActivityKind, + EngagementStatus, + Enrollment, + LessonProgress, +) +from apps.payments.models import Gateway, Order, OrderStatus + +User = get_user_model() + + +def _teacher(phone="09120000001", approved=True): + u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER) + TeacherProfile.objects.create(user=u, is_approved=approved) + return u + + +def _student(phone="09120000099", name="Student"): + return User.objects.create_user(phone_number=phone, full_name=name, role=Role.STUDENT) + + +def _course_with_lessons(instructor, *, n_lessons=3, published=True): + course = Course.objects.create( + instructor=instructor, title="C", tagline="T", description="D", + level="beginner", language="fa", + ) + if published: + course.publish() + sec = Section.objects.create(course=course, title="S1", order=0) + lessons = [] + for i in range(n_lessons): + l = Lesson.objects.create( + section=sec, + title=f"L{i+1}", + kind=LessonKind.VIDEO, + video_url=f"https://e.com/{i}", + status=LessonStatus.PUBLISHED, + order=i, + ) + lessons.append(l) + return course, sec, lessons + + +# ======================================================================== ENROLL + +class EnrollAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = _teacher() + self.student = _student() + self.course, _, _ = _course_with_lessons(self.teacher) + + def test_anon_blocked(self): + r = self.client.post(f"/api/courses/{self.course.slug}/enroll/") + self.assertEqual(r.status_code, 401) + + def test_teacher_cannot_enroll(self): + self.client.force_authenticate(self.teacher) + r = self.client.post(f"/api/courses/{self.course.slug}/enroll/") + self.assertEqual(r.status_code, 403) + + def test_student_enrolls_201_then_200(self): + self.client.force_authenticate(self.student) + r1 = self.client.post(f"/api/courses/{self.course.slug}/enroll/") + self.assertEqual(r1.status_code, 201, r1.content) + self.assertTrue(Enrollment.objects.filter(student=self.student, course=self.course).exists()) + r2 = self.client.post(f"/api/courses/{self.course.slug}/enroll/") + self.assertEqual(r2.status_code, 200) # idempotent + self.assertEqual(Enrollment.objects.filter(student=self.student, course=self.course).count(), 1) + + def test_enroll_404_for_unpublished(self): + draft, _, _ = _course_with_lessons(self.teacher, published=False) + self.client.force_authenticate(self.student) + r = self.client.post(f"/api/courses/{draft.slug}/enroll/") + self.assertEqual(r.status_code, 404) + + +# ======================================================================== MY COURSES + +class MyCoursesAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = _teacher() + self.student = _student() + + def test_my_courses_lists_progress_and_next_lesson(self): + course, sec, lessons = _course_with_lessons(self.teacher, n_lessons=4) + e = Enrollment.objects.create(student=self.student, course=course) + # complete the first 2 lessons + from django.utils import timezone + for l in lessons[:2]: + LessonProgress.objects.create( + enrollment=e, lesson=l, completed_at=timezone.now() + ) + self.client.force_authenticate(self.student) + r = self.client.get("/api/me/courses/") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertEqual(len(body), 1) + row = body[0] + self.assertEqual(row["progress_percent"], 50) + self.assertEqual(row["completed_lessons"], 2) + self.assertEqual(row["total_lessons"], 4) + self.assertEqual(row["last_lesson_id"], lessons[1].id) + self.assertEqual(row["next_lesson_id"], lessons[2].id) + + +class MyCourseDetailAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = _teacher() + self.student = _student() + self.course, self.sec, self.lessons = _course_with_lessons(self.teacher, n_lessons=2) + + def test_403_when_not_enrolled(self): + self.client.force_authenticate(self.student) + r = self.client.get(f"/api/me/courses/{self.course.slug}/") + self.assertEqual(r.status_code, 403) + + def test_full_content_unlocked_when_enrolled(self): + Enrollment.objects.create(student=self.student, course=self.course) + self.client.force_authenticate(self.student) + r = self.client.get(f"/api/me/courses/{self.course.slug}/") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertEqual(len(body["sections"]), 1) + lessons = body["sections"][0]["lessons"] + # both lessons present, with full video_url (no preview gating) + urls = [l["video_url"] for l in lessons] + self.assertEqual(urls, ["https://e.com/0", "https://e.com/1"]) + # enrollment block + self.assertEqual(body["enrollment"]["progress_percent"], 0) + + +# ======================================================================== PROGRESS + +class LessonProgressAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = _teacher() + self.student = _student() + self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=3) + self.enrollment = Enrollment.objects.create(student=self.student, course=self.course) + self.client.force_authenticate(self.student) + + def test_complete_lesson(self): + l = self.lessons[0] + r = self.client.post(f"/api/me/lessons/{l.id}/complete/") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertIsNotNone(body["completed_at"]) + self.assertEqual(body["progress_percent"], 33) + self.assertFalse(body["course_completed"]) + self.assertTrue( + LessonProgress.objects.filter(enrollment=self.enrollment, lesson=l).exists() + ) + + def test_complete_idempotent(self): + l = self.lessons[0] + self.client.post(f"/api/me/lessons/{l.id}/complete/") + first_completed = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at + self.client.post(f"/api/me/lessons/{l.id}/complete/") + second = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at + self.assertEqual(first_completed, second) + self.assertEqual( + LessonProgress.objects.filter(enrollment=self.enrollment).count(), 1 + ) + + def test_completing_all_marks_enrollment_completed(self): + for l in self.lessons: + self.client.post(f"/api/me/lessons/{l.id}/complete/") + self.enrollment.refresh_from_db() + self.assertIsNotNone(self.enrollment.completed_at) + + def test_uncomplete_clears_enrollment_completion(self): + for l in self.lessons: + self.client.post(f"/api/me/lessons/{l.id}/complete/") + self.enrollment.refresh_from_db() + self.assertIsNotNone(self.enrollment.completed_at) + # un-complete one + r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/uncomplete/") + self.assertEqual(r.status_code, 200) + self.enrollment.refresh_from_db() + self.assertIsNone(self.enrollment.completed_at) + + def test_progress_for_lesson_in_course_user_is_not_enrolled_in(self): + other_teacher = _teacher(phone="09120000050") + other_course, _, other_lessons = _course_with_lessons(other_teacher) + r = self.client.post(f"/api/me/lessons/{other_lessons[0].id}/complete/") + self.assertEqual(r.status_code, 403) + + +# ======================================================================== INSTRUCTOR ENROLLMENTS + +class InstructorEnrollmentsTests(TestCase): + def test_instructor_sees_their_courses_enrollments(self): + client = APIClient() + teacher = _teacher() + s1 = _student(phone="09120000091") + s2 = _student(phone="09120000092") + course, _, _ = _course_with_lessons(teacher) + Enrollment.objects.create(student=s1, course=course) + Enrollment.objects.create(student=s2, course=course) + client.force_authenticate(teacher) + r = client.get(f"/api/instructor/courses/{course.id}/enrollments/") + self.assertEqual(r.status_code, 200, r.content) + self.assertEqual(len(r.json()), 2) + + def test_instructor_cannot_see_others_enrollments(self): + client = APIClient() + owner = _teacher(phone="09120000010") + intruder = _teacher(phone="09120000011") + course, _, _ = _course_with_lessons(owner) + Enrollment.objects.create(student=_student(), course=course) + client.force_authenticate(intruder) + r = client.get(f"/api/instructor/courses/{course.id}/enrollments/") + self.assertEqual(r.status_code, 403) + + +# ======================================================================== ANNOUNCEMENTS + +class AnnouncementAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = _teacher() + self.student = _student() + self.course, _, _ = _course_with_lessons(self.teacher) + + def test_instructor_creates_announcement(self): + self.client.force_authenticate(self.teacher) + r = self.client.post( + "/api/instructor/announcements/", + {"course": self.course.id, "body": "Welcome!"}, + format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + a = Announcement.objects.get(course=self.course) + self.assertEqual(a.posted_by, self.teacher) + + def test_instructor_cannot_post_on_others_course(self): + intruder = _teacher(phone="09120000050") + self.client.force_authenticate(intruder) + r = self.client.post( + "/api/instructor/announcements/", + {"course": self.course.id, "body": "Sneaky"}, + format="json", + ) + self.assertEqual(r.status_code, 403) + + def test_enrolled_student_reads_announcements(self): + Enrollment.objects.create(student=self.student, course=self.course) + Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher) + self.client.force_authenticate(self.student) + r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertEqual(len(body), 1) + self.assertEqual(body[0]["body"], "Hello") + + def test_non_enrolled_student_blocked(self): + Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher) + self.client.force_authenticate(self.student) + r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/") + self.assertEqual(r.status_code, 403) + + +# ============================================================ ENGAGEMENT STATUS + +class EngagementStatusTests(TestCase): + def setUp(self): + self.teacher = _teacher() + self.course, _, _ = _course_with_lessons(self.teacher) + + def _enroll(self, student, completed=False): + e = Enrollment.objects.create(student=student, course=self.course) + if completed: + e.completed_at = timezone.now() + e.save() + return e + + def test_completed_overrides_activity(self): + s = _student(phone="09120000010") + e = self._enroll(s, completed=True) + self.assertEqual(e.engagement_status(), EngagementStatus.COMPLETED.value) + + def test_active_when_recent_activity(self): + s = _student(phone="09120000011") + s.last_active_at = timezone.now() - timedelta(days=2) + s.save() + e = self._enroll(s) + self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value) + + def test_at_risk_after_7_days(self): + s = _student(phone="09120000012") + s.last_active_at = timezone.now() - timedelta(days=10) + s.save() + e = self._enroll(s) + self.assertEqual(e.engagement_status(), EngagementStatus.AT_RISK.value) + + def test_inactive_after_14_days(self): + s = _student(phone="09120000013") + s.last_active_at = timezone.now() - timedelta(days=20) + s.save() + e = self._enroll(s) + self.assertEqual(e.engagement_status(), EngagementStatus.INACTIVE.value) + + def test_null_last_active_treated_as_active(self): + s = _student(phone="09120000014") + e = self._enroll(s) + self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value) + + +# ============================================================ ACTIVITY EVENTS + +class ActivityEventRecordingTests(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = _teacher() + self.student = _student() + self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=2) + + def test_free_enrollment_records_event(self): + # the course was created with paid_course default, but _course_with_lessons doesn't set price + # so it's free + self.client.force_authenticate(self.student) + r = self.client.post(f"/api/courses/{self.course.slug}/enroll/") + self.assertEqual(r.status_code, 201) + events = ActivityEvent.objects.filter(course=self.course, kind=ActivityKind.ENROLLED.value) + self.assertEqual(events.count(), 1) + self.assertEqual(events.first().payload["via"], "free") + + def test_lesson_completion_records_event(self): + Enrollment.objects.create(student=self.student, course=self.course) + self.client.force_authenticate(self.student) + r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/complete/") + self.assertEqual(r.status_code, 200) + e = ActivityEvent.objects.filter(kind=ActivityKind.LESSON_COMPLETED.value).first() + self.assertIsNotNone(e) + self.assertEqual(e.payload["lesson_id"], self.lessons[0].id) + + def test_completing_all_lessons_records_course_completed(self): + Enrollment.objects.create(student=self.student, course=self.course) + self.client.force_authenticate(self.student) + for l in self.lessons: + self.client.post(f"/api/me/lessons/{l.id}/complete/") + completed_events = ActivityEvent.objects.filter( + kind=ActivityKind.COURSE_COMPLETED.value + ) + self.assertEqual(completed_events.count(), 1) + + def test_announcement_post_records_event(self): + self.client.force_authenticate(self.teacher) + self.client.post( + "/api/instructor/announcements/", + {"course": self.course.id, "body": "Welcome"}, + format="json", + ) + e = ActivityEvent.objects.filter(kind=ActivityKind.ANNOUNCEMENT_POSTED.value).first() + self.assertIsNotNone(e) + self.assertIn("Welcome", e.payload["preview"]) + + def test_paid_checkout_records_enrolled_and_paid(self): + from django.test import override_settings + + paid = Course.objects.create( + instructor=self.teacher, title="Paid", tagline="T", description="D", + level="beginner", language="fa", price_toman=1000, + ) + paid.publish() + with override_settings(PAYMENT_BACKEND="apps.payments.services.gateway.ConsolePaymentBackend"): + self.client.force_authenticate(self.student) + co = self.client.post(f"/api/courses/{paid.slug}/checkout/").json() + self.client.get( + f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}" + ) + kinds = list( + ActivityEvent.objects.filter(course=paid).values_list("kind", flat=True) + ) + self.assertIn(ActivityKind.ENROLLED.value, kinds) + self.assertIn(ActivityKind.PAID.value, kinds) + + +# ============================================================ ANALYTICS ENDPOINTS + +class InstructorAnalyticsTests(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = _teacher() + self.student = _student() + self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=4) + + def test_kpis_blocked_for_non_teacher(self): + self.client.force_authenticate(self.student) + r = self.client.get("/api/instructor/kpis/") + self.assertEqual(r.status_code, 403) + + def test_kpis_returns_four_cards(self): + self.client.force_authenticate(self.teacher) + r = self.client.get("/api/instructor/kpis/") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + ids = [c["id"] for c in body] + self.assertEqual(set(ids), {"revenue", "active_students", "avg_progress", "satisfaction"}) + for card in body: + self.assertIn("value", card) + self.assertIn("spark", card) + self.assertEqual(len(card["spark"]), 12) + + def test_kpi_revenue_reflects_paid_orders(self): + # Create a paid order this month + Order.objects.create( + student=self.student, course=self.course, + amount_toman=1_000_000, ilo_fee_toman=150_000, instructor_share_toman=850_000, + commission_percent_snapshot=15, gateway=Gateway.CONSOLE, + status=OrderStatus.PAID, paid_at=timezone.now(), + ) + self.client.force_authenticate(self.teacher) + r = self.client.get("/api/instructor/kpis/") + revenue_card = next(c for c in r.json() if c["id"] == "revenue") + self.assertEqual(revenue_card["value"], 850_000) + + def test_kpi_avg_progress_from_enrollments(self): + # one enrollment with 50% progress + e = Enrollment.objects.create(student=self.student, course=self.course) + for l in self.lessons[:2]: + LessonProgress.objects.create( + enrollment=e, lesson=l, completed_at=timezone.now() + ) + self.client.force_authenticate(self.teacher) + r = self.client.get("/api/instructor/kpis/") + avg_card = next(c for c in r.json() if c["id"] == "avg_progress") + self.assertEqual(avg_card["value"], 50) + + def test_monthly_revenue_returns_12_months(self): + self.client.force_authenticate(self.teacher) + r = self.client.get("/api/instructor/revenue/monthly/") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertEqual(len(body), 12) + for row in body: + self.assertIn("month", row) + self.assertIn("revenue_toman", row) + self.assertIn("orders_count", row) + + def test_activity_feed_lists_events_for_my_courses_only(self): + # make 2 events for my course + from apps.enrollments.services.events import record_event + + record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value) + record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value) + # make 1 event on someone else's course + other_t = _teacher(phone="09120000050") + other_course, _, _ = _course_with_lessons(other_t) + record_event(course=other_course, actor=self.student, kind=ActivityKind.ENROLLED.value) + + self.client.force_authenticate(self.teacher) + r = self.client.get("/api/instructor/activity/") + self.assertEqual(r.status_code, 200) + results = r.json()["results"] + self.assertEqual(len(results), 2) + self.assertTrue(all(row["course"] == self.course.id for row in results)) + + def test_activity_feed_filter_by_kind(self): + from apps.enrollments.services.events import record_event + + record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value) + record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value) + self.client.force_authenticate(self.teacher) + r = self.client.get("/api/instructor/activity/?kind=enrolled") + results = r.json()["results"] + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["kind"], "enrolled") + + +# ============================================================ ENGAGEMENT FILTER + +class CourseEnrollmentsCRMTests(TestCase): + def test_engagement_status_in_payload_and_filter(self): + client = APIClient() + teacher = _teacher() + course, _, _ = _course_with_lessons(teacher) + + # an active student + a = _student(phone="09120000091") + a.last_active_at = timezone.now() + a.save() + Enrollment.objects.create(student=a, course=course) + + # an at-risk student + r_student = _student(phone="09120000092") + r_student.last_active_at = timezone.now() - timedelta(days=10) + r_student.save() + Enrollment.objects.create(student=r_student, course=course) + + client.force_authenticate(teacher) + r = client.get(f"/api/instructor/courses/{course.id}/enrollments/") + self.assertEqual(r.status_code, 200) + rows = r.json() + statuses = sorted(row["engagement_status"] for row in rows) + self.assertEqual(statuses, ["active", "at_risk"]) + + r = client.get(f"/api/instructor/courses/{course.id}/enrollments/?engagement_status=at_risk") + rows = r.json() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["student_id"], r_student.id) diff --git a/apps/payments/__init__.py b/apps/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/admin.py b/apps/payments/admin.py new file mode 100644 index 0000000..9170730 --- /dev/null +++ b/apps/payments/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin + +from .models import Order, PromoCode, Referral + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ( + "id", + "student", + "course", + "base_amount_toman", + "discount_amount_toman", + "amount_toman", + "instructor_share_toman", + "gateway", + "status", + "created_at", + "paid_at", + ) + list_filter = ("status", "gateway", "course") + search_fields = ("student__phone_number", "course__slug", "authority", "ref_id", "promo_code__code") + raw_id_fields = ("student", "course", "promo_code", "referrer") + readonly_fields = ( + "base_amount_toman", + "discount_amount_toman", + "amount_toman", + "ilo_fee_toman", + "instructor_share_toman", + "commission_percent_snapshot", + "gateway", + "authority", + "ref_id", + "created_at", + "paid_at", + "failed_at", + "last_error", + ) + date_hierarchy = "created_at" + + +@admin.register(PromoCode) +class PromoCodeAdmin(admin.ModelAdmin): + list_display = ("code", "discount_percent", "discount_amount_toman", "course", "used_count", "max_uses", "is_active", "valid_until") + list_filter = ("is_active", "course") + search_fields = ("code",) + raw_id_fields = ("course",) + + +@admin.register(Referral) +class ReferralAdmin(admin.ModelAdmin): + list_display = ("referrer", "buyer", "course", "buyer_discount_toman", "referrer_credit_toman", "settled_at") + search_fields = ("referrer__phone_number", "buyer__phone_number", "course__slug") + raw_id_fields = ("referrer", "buyer", "order", "course") + readonly_fields = ("settled_at",) diff --git a/apps/payments/api/__init__.py b/apps/payments/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/api/preview.py b/apps/payments/api/preview.py new file mode 100644 index 0000000..905aa84 --- /dev/null +++ b/apps/payments/api/preview.py @@ -0,0 +1,72 @@ +"""Validate-promo endpoint — gives the frontend a preview of price + discount.""" + +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer +from rest_framework import serializers +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.courses.models import Course, CourseStatus +from apps.enrollments.api.permissions import IsStudent +from apps.payments.services.orders import OrderError, _resolve_discount + + +class PromoPreviewRequestSerializer(serializers.Serializer): + promo_code = serializers.CharField(required=False, allow_blank=True) + referral_code = serializers.CharField(required=False, allow_blank=True) + + +class PromoPreviewResponseSerializer(serializers.Serializer): + course_slug = serializers.CharField() + base_amount_toman = serializers.IntegerField() + discount_amount_toman = serializers.IntegerField() + final_amount_toman = serializers.IntegerField() + promo_code_applied = serializers.CharField(allow_null=True) + referrer_code_applied = serializers.CharField(allow_null=True) + + +class CheckoutPreviewView(APIView): + """`POST /api/courses//checkout/preview/` — dry-run price computation.""" + + permission_classes = [IsAuthenticated, IsStudent] + + @extend_schema( + tags=["payments"], + summary="Preview checkout price (apply promo / referral code without committing)", + request=PromoPreviewRequestSerializer, + responses={ + 200: PromoPreviewResponseSerializer, + 400: OpenApiResponse(description="Code invalid / not applicable / both supplied / course is free."), + 404: OpenApiResponse(description="Course not found or not published."), + }, + ) + def post(self, request, slug): + course = get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED) + if course.is_free: + return Response( + {"detail": "Course is free; no checkout needed."}, + status=400, + ) + ser = PromoPreviewRequestSerializer(data=request.data) + ser.is_valid(raise_exception=True) + base = int(course.price_toman or 0) + try: + discount, pc_obj, referrer, _credit = _resolve_discount( + course, base, + promo_code=ser.validated_data.get("promo_code") or None, + referral_code=ser.validated_data.get("referral_code") or None, + buyer=request.user, + ) + except OrderError as e: + return Response({"detail": str(e)}, status=400) + final = max(0, base - discount) + body = { + "course_slug": course.slug, + "base_amount_toman": base, + "discount_amount_toman": discount, + "final_amount_toman": final, + "promo_code_applied": pc_obj.code if pc_obj else None, + "referrer_code_applied": referrer.referral_code if referrer else None, + } + return Response(PromoPreviewResponseSerializer(body).data) diff --git a/apps/payments/api/serializers.py b/apps/payments/api/serializers.py new file mode 100644 index 0000000..d6d1f84 --- /dev/null +++ b/apps/payments/api/serializers.py @@ -0,0 +1,70 @@ +from rest_framework import serializers + +from apps.payments.models import Order + + +class CheckoutResponseSerializer(serializers.Serializer): + order_id = serializers.IntegerField() + redirect_url = serializers.URLField() + amount_toman = serializers.IntegerField() + gateway = serializers.CharField() + + +class OrderSerializer(serializers.ModelSerializer): + course_id = serializers.IntegerField(read_only=True) + course_slug = serializers.CharField(source="course.slug", read_only=True) + course_title = serializers.CharField(source="course.title", read_only=True) + promo_code = serializers.CharField(source="promo_code.code", read_only=True, allow_null=True) + referrer_code = serializers.CharField(source="referrer.referral_code", read_only=True, allow_null=True) + + class Meta: + model = Order + fields = [ + "id", + "course_id", + "course_slug", + "course_title", + "base_amount_toman", + "discount_amount_toman", + "amount_toman", + "ilo_fee_toman", + "instructor_share_toman", + "commission_percent_snapshot", + "gateway", + "ref_id", + "status", + "created_at", + "paid_at", + "failed_at", + "last_error", + "promo_code", + "referrer_code", + ] + read_only_fields = fields + + +class InstructorSaleSerializer(serializers.ModelSerializer): + """Sale entry shown in the instructor's revenue / orders panel.""" + + course_id = serializers.IntegerField(read_only=True) + course_title = serializers.CharField(source="course.title", read_only=True) + student_id = serializers.IntegerField(source="student.id", read_only=True) + student_name = serializers.CharField(source="student.full_name", read_only=True) + + class Meta: + model = Order + fields = [ + "id", + "course_id", + "course_title", + "student_id", + "student_name", + "amount_toman", + "ilo_fee_toman", + "instructor_share_toman", + "commission_percent_snapshot", + "status", + "created_at", + "paid_at", + ] + read_only_fields = fields diff --git a/apps/payments/api/urls.py b/apps/payments/api/urls.py new file mode 100644 index 0000000..84b7fa7 --- /dev/null +++ b/apps/payments/api/urls.py @@ -0,0 +1,27 @@ +from django.urls import path + +from .preview import CheckoutPreviewView +from .views import ( + CheckoutView, + GatewayCallbackView, + InstructorSalesView, + MyOrderDetailView, + MyOrdersView, +) + +urlpatterns = [ + path("courses//checkout/", CheckoutView.as_view(), name="checkout"), + path( + "courses//checkout/preview/", + CheckoutPreviewView.as_view(), + name="checkout-preview", + ), + path( + "payments/callback//", + GatewayCallbackView.as_view(), + name="payment-callback", + ), + path("me/orders/", MyOrdersView.as_view(), name="my-orders"), + path("me/orders//", MyOrderDetailView.as_view(), name="my-order-detail"), + path("instructor/orders/", InstructorSalesView.as_view(), name="instructor-orders"), +] diff --git a/apps/payments/api/views.py b/apps/payments/api/views.py new file mode 100644 index 0000000..a7176e4 --- /dev/null +++ b/apps/payments/api/views.py @@ -0,0 +1,215 @@ +from urllib.parse import urlencode + +from django.conf import settings +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.courses.models import Course, CourseStatus +from apps.enrollments.api.permissions import IsStudent +from apps.payments.models import Order, OrderStatus +from apps.payments.services.gateway import PaymentGatewayError +from apps.payments.services.orders import ( + OrderError, + complete_or_fail, + start_checkout, +) + +from .serializers import ( + CheckoutResponseSerializer, + InstructorSaleSerializer, + OrderSerializer, +) + + +class CheckoutView(APIView): + permission_classes = [IsAuthenticated, IsStudent] + + @extend_schema( + tags=["payments"], + summary="Start checkout for a paid course", + description=( + "Creates a pending Order, opens an intent on the configured payment gateway, " + "and returns the URL the user's browser must be redirected to in order to pay. " + "After the user finishes (or abandons) at the gateway, they are bounced back to " + "`/api/payments/callback//`, which verifies the payment, marks the order " + "paid, and creates the Enrollment." + ), + request=None, + responses={ + 201: CheckoutResponseSerializer, + 400: OpenApiResponse(description="Course is free, or you're already enrolled."), + 402: OpenApiResponse(description="Gateway refused the request (auto-failed order)."), + 403: OpenApiResponse(description="Not a student."), + 404: OpenApiResponse(description="Course not found or not published."), + }, + ) + def post(self, request, slug): + course = get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED) + promo = (request.data or {}).get("promo_code") if hasattr(request, "data") else None + ref = (request.data or {}).get("referral_code") if hasattr(request, "data") else None + try: + order, redirect_url = start_checkout( + request.user, course, promo_code=promo, referral_code=ref, + ) + except OrderError as e: + raise ValidationError({"detail": str(e)}) + except PaymentGatewayError as e: + return Response( + {"detail": "Could not start payment", "error": str(e)}, + status=status.HTTP_402_PAYMENT_REQUIRED, + ) + body = { + "order_id": order.pk, + "redirect_url": redirect_url, + "amount_toman": order.amount_toman, + "gateway": order.gateway, + } + return Response(CheckoutResponseSerializer(body).data, status=status.HTTP_201_CREATED) + + +class GatewayCallbackView(APIView): + """Public endpoint the gateway redirects the browser back to after a payment. + + Always responds with an HTTP redirect to the configured success/failure URL — + those are frontend pages, not API endpoints. + """ + + permission_classes = [AllowAny] + authentication_classes = [] # gateway-redirected; no JWT in flight + + @extend_schema( + tags=["payments"], + summary="Gateway callback (server-to-browser-to-server)", + description=( + "ZarinPal (or the configured gateway) redirects the user's browser here after they " + "complete or cancel payment. We look up the Order by `order_id`, verify with the " + "gateway using the stored `authority`, mark the order as paid or failed, create the " + "Enrollment on success, and finally HTTP-redirect the browser to the configured " + "success or failure URL." + ), + parameters=[ + OpenApiParameter(name="order_id", required=True, type=int, location=OpenApiParameter.QUERY), + OpenApiParameter(name="Authority", required=False, type=str, location=OpenApiParameter.QUERY), + OpenApiParameter(name="Status", required=False, type=str, location=OpenApiParameter.QUERY), + ], + responses={302: OpenApiResponse(description="Redirect to success/failure URL.")}, + ) + def get(self, request, gateway): + order_id = request.GET.get("order_id") + if not order_id: + return self._fail_redirect("missing order_id") + gateway_status = request.GET.get("Status", "") + + with transaction.atomic(): + try: + order = Order.objects.select_for_update().get(pk=order_id, gateway=gateway) + except Order.DoesNotExist: + return self._fail_redirect("unknown order") + ok, msg = complete_or_fail(order, gateway_status) + + if ok: + return self._success_redirect(order) + return self._fail_redirect(msg, order=order) + + @staticmethod + def _success_redirect(order: Order): + url = getattr(settings, "PAYMENT_SUCCESS_REDIRECT_URL", "/payment/success") + qs = urlencode({"order_id": order.pk, "ref_id": order.ref_id or ""}) + return redirect(f"{url}?{qs}" if "?" not in url else f"{url}&{qs}") + + @staticmethod + def _fail_redirect(reason: str, order: Order | None = None): + url = getattr(settings, "PAYMENT_FAILURE_REDIRECT_URL", "/payment/failed") + params = {"reason": reason} + if order is not None: + params["order_id"] = order.pk + qs = urlencode(params) + return redirect(f"{url}?{qs}" if "?" not in url else f"{url}&{qs}") + + +class MyOrdersView(ListAPIView): + permission_classes = [IsAuthenticated, IsStudent] + serializer_class = OrderSerializer + pagination_class = None + + @extend_schema( + tags=["payments"], + summary="My order history", + description="Lists the current student's orders (any status), most recent first.", + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return ( + Order.objects.filter(student=self.request.user) + .select_related("course") + .order_by("-created_at") + ) + + +class MyOrderDetailView(RetrieveAPIView): + """One Order's full receipt — used by the checkout success page.""" + + permission_classes = [IsAuthenticated, IsStudent] + serializer_class = OrderSerializer + lookup_field = "pk" + + @extend_schema( + tags=["payments"], + summary="Order detail (receipt)", + description=( + "Returns the order's amount, fees, instructor share, gateway, ref id, status and " + "course summary. Owner-only — students can only fetch their own orders." + ), + responses={ + 200: OrderSerializer, + 404: OpenApiResponse(description="Not found or not owned."), + }, + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return ( + Order.objects.filter(student=self.request.user) + .select_related("course") + ) + + +class InstructorSalesView(ListAPIView): + """Instructor's paid sales across all their courses.""" + + permission_classes = [IsAuthenticated] + serializer_class = InstructorSaleSerializer + pagination_class = None + filterset_fields = ["status", "course"] + + @extend_schema( + tags=["payments"], + summary="My sales (instructor)", + description=( + "Lists orders for the instructor's own courses. Defaults to all statuses; pass " + "`?status=paid` to filter to settled sales." + ), + ) + def get(self, request, *args, **kwargs): + from apps.accounts.models import Role + + if request.user.role != Role.TEACHER: + raise PermissionDenied("Only teachers can view sales.") + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return ( + Order.objects.filter(course__instructor=self.request.user) + .select_related("student", "course") + .order_by("-created_at") + ) diff --git a/apps/payments/apps.py b/apps/payments/apps.py new file mode 100644 index 0000000..061ccaf --- /dev/null +++ b/apps/payments/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.payments" + label = "payments" diff --git a/apps/payments/migrations/0001_initial.py b/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..d263e29 --- /dev/null +++ b/apps/payments/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.15 on 2026-05-02 15:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('courses', '0002_course_original_price_toman_course_price_toman'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount_toman', models.PositiveIntegerField(verbose_name='amount (Toman)')), + ('ilo_fee_toman', models.PositiveIntegerField(default=0, verbose_name='platform fee (Toman)')), + ('instructor_share_toman', models.PositiveIntegerField(default=0, verbose_name='instructor share (Toman)')), + ('commission_percent_snapshot', models.PositiveSmallIntegerField(default=0, verbose_name='commission % at order time')), + ('gateway', models.CharField(choices=[('console', 'Console (dev)'), ('zarinpal', 'ZarinPal')], max_length=16, verbose_name='gateway')), + ('authority', models.CharField(blank=True, db_index=True, max_length=100, verbose_name='authority')), + ('ref_id', models.CharField(blank=True, max_length=64, verbose_name='ref ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=16, verbose_name='status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')), + ('failed_at', models.DateTimeField(blank=True, null=True, verbose_name='failed at')), + ('last_error', models.CharField(blank=True, max_length=255, verbose_name='last error')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='courses.course')), + ('student', models.ForeignKey(limit_choices_to={'role': 'student'}, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'order', + 'verbose_name_plural': 'orders', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['student', '-created_at'], name='payments_or_student_4b23c9_idx'), models.Index(fields=['course', '-created_at'], name='payments_or_course__78a038_idx'), models.Index(fields=['status'], name='payments_or_status_daab12_idx')], + }, + ), + ] diff --git a/apps/payments/migrations/0002_order_base_amount_toman_order_discount_amount_toman_and_more.py b/apps/payments/migrations/0002_order_base_amount_toman_order_discount_amount_toman_and_more.py new file mode 100644 index 0000000..015d52d --- /dev/null +++ b/apps/payments/migrations/0002_order_base_amount_toman_order_discount_amount_toman_and_more.py @@ -0,0 +1,98 @@ +# Generated by Django 5.1.15 on 2026-05-02 16:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0004_course_discount_ends_at_course_discount_starts_at'), + ('payments', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='base_amount_toman', + field=models.PositiveIntegerField(default=0, help_text='Course price at order time, before any discount.', verbose_name='base amount (Toman)'), + ), + migrations.AddField( + model_name='order', + name='discount_amount_toman', + field=models.PositiveIntegerField(default=0, verbose_name='discount applied (Toman)'), + ), + migrations.AddField( + model_name='order', + name='referrer', + field=models.ForeignKey(blank=True, help_text='The user whose referral_code was applied at checkout.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referred_orders', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='order', + name='amount_toman', + field=models.PositiveIntegerField(help_text='base_amount - discount; what the buyer actually pays.', verbose_name='charged amount (Toman)'), + ), + migrations.AlterField( + model_name='order', + name='gateway', + field=models.CharField(choices=[('console', 'Console (dev)'), ('zarinpal', 'ZarinPal'), ('idpay', 'IDPay'), ('free', 'Free (auto)')], max_length=16, verbose_name='gateway'), + ), + migrations.CreateModel( + name='PromoCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=32, unique=True, verbose_name='code')), + ('discount_percent', models.PositiveSmallIntegerField(blank=True, help_text='Percentage off (1-100). Mutually exclusive with discount_amount_toman.', null=True, verbose_name='discount %')), + ('discount_amount_toman', models.PositiveIntegerField(blank=True, help_text='Fixed amount off. Mutually exclusive with discount_percent.', null=True, verbose_name='discount amount (Toman)')), + ('max_uses', models.PositiveIntegerField(blank=True, help_text='Total uses across all users. Null = unlimited.', null=True, verbose_name='max uses')), + ('used_count', models.PositiveIntegerField(default=0, verbose_name='used count')), + ('valid_from', models.DateTimeField(blank=True, null=True, verbose_name='valid from')), + ('valid_until', models.DateTimeField(blank=True, null=True, verbose_name='valid until')), + ('is_active', models.BooleanField(default=True, verbose_name='active')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('course', models.ForeignKey(blank=True, help_text='If set, code only applies to this course; otherwise applies to any course.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='promo_codes', to='courses.course')), + ], + options={ + 'verbose_name': 'promo code', + 'verbose_name_plural': 'promo codes', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='order', + name='promo_code', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='payments.promocode'), + ), + migrations.CreateModel( + name='Referral', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('buyer_discount_toman', models.PositiveIntegerField(default=0, verbose_name='buyer discount (Toman)')), + ('referrer_credit_toman', models.PositiveIntegerField(default=0, verbose_name='referrer credit (Toman)')), + ('settled_at', models.DateTimeField(auto_now_add=True, verbose_name='settled at')), + ('buyer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='referrals_received', to=settings.AUTH_USER_MODEL)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='referrals', to='courses.course')), + ('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='referral', to='payments.order')), + ('referrer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='referrals_given', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'referral', + 'verbose_name_plural': 'referrals', + 'ordering': ['-settled_at'], + }, + ), + migrations.AddIndex( + model_name='promocode', + index=models.Index(fields=['code'], name='payments_pr_code_255a5d_idx'), + ), + migrations.AddIndex( + model_name='referral', + index=models.Index(fields=['referrer', '-settled_at'], name='payments_re_referre_16a032_idx'), + ), + migrations.AddIndex( + model_name='referral', + index=models.Index(fields=['buyer', '-settled_at'], name='payments_re_buyer_i_779881_idx'), + ), + ] diff --git a/apps/payments/migrations/__init__.py b/apps/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/models.py b/apps/payments/models.py new file mode 100644 index 0000000..3c139db --- /dev/null +++ b/apps/payments/models.py @@ -0,0 +1,196 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class OrderStatus(models.TextChoices): + PENDING = "pending", _("Pending") + PAID = "paid", _("Paid") + FAILED = "failed", _("Failed") + CANCELLED = "cancelled", _("Cancelled") + + +class Gateway(models.TextChoices): + CONSOLE = "console", _("Console (dev)") + ZARINPAL = "zarinpal", _("ZarinPal") + IDPAY = "idpay", _("IDPay") + FREE = "free", _("Free (auto)") + + +class PromoCode(models.Model): + code = models.CharField(_("code"), max_length=32, unique=True) + discount_percent = models.PositiveSmallIntegerField( + _("discount %"), null=True, blank=True, + help_text=_("Percentage off (1-100). Mutually exclusive with discount_amount_toman."), + ) + discount_amount_toman = models.PositiveIntegerField( + _("discount amount (Toman)"), null=True, blank=True, + help_text=_("Fixed amount off. Mutually exclusive with discount_percent."), + ) + course = models.ForeignKey( + "courses.Course", on_delete=models.CASCADE, related_name="promo_codes", + null=True, blank=True, + help_text=_("If set, code only applies to this course; otherwise applies to any course."), + ) + max_uses = models.PositiveIntegerField( + _("max uses"), null=True, blank=True, + help_text=_("Total uses across all users. Null = unlimited."), + ) + used_count = models.PositiveIntegerField(_("used count"), default=0) + valid_from = models.DateTimeField(_("valid from"), null=True, blank=True) + valid_until = models.DateTimeField(_("valid until"), null=True, blank=True) + is_active = models.BooleanField(_("active"), default=True) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + + class Meta: + verbose_name = _("promo code") + verbose_name_plural = _("promo codes") + ordering = ["-created_at"] + indexes = [models.Index(fields=["code"])] + + def __str__(self): + return self.code + + def save(self, *args, **kwargs): + if self.code: + self.code = self.code.strip().upper() + super().save(*args, **kwargs) + + def is_valid_now(self) -> bool: + if not self.is_active: + return False + now = timezone.now() + if self.valid_from and now < self.valid_from: + return False + if self.valid_until and now >= self.valid_until: + return False + if self.max_uses is not None and self.used_count >= self.max_uses: + return False + if (self.discount_percent or 0) <= 0 and (self.discount_amount_toman or 0) <= 0: + return False + return True + + def applies_to_course(self, course) -> bool: + return self.course_id is None or self.course_id == course.id + + def discount_for(self, base_amount_toman: int) -> int: + """Return the Toman discount this code yields on `base_amount_toman`.""" + if self.discount_percent: + return min(base_amount_toman, (base_amount_toman * int(self.discount_percent)) // 100) + if self.discount_amount_toman: + return min(base_amount_toman, int(self.discount_amount_toman)) + return 0 + + +class Order(models.Model): + student = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="orders", + limit_choices_to={"role": "student"}, + ) + course = models.ForeignKey( + "courses.Course", + on_delete=models.PROTECT, + related_name="orders", + ) + + # Money — snapshotted at order creation so price/commission changes don't affect old orders. + base_amount_toman = models.PositiveIntegerField( + _("base amount (Toman)"), default=0, + help_text=_("Course price at order time, before any discount."), + ) + discount_amount_toman = models.PositiveIntegerField( + _("discount applied (Toman)"), default=0, + ) + amount_toman = models.PositiveIntegerField( + _("charged amount (Toman)"), + help_text=_("base_amount - discount; what the buyer actually pays."), + ) + ilo_fee_toman = models.PositiveIntegerField(_("platform fee (Toman)"), default=0) + instructor_share_toman = models.PositiveIntegerField(_("instructor share (Toman)"), default=0) + commission_percent_snapshot = models.PositiveSmallIntegerField( + _("commission % at order time"), default=0 + ) + + promo_code = models.ForeignKey( + PromoCode, on_delete=models.SET_NULL, related_name="orders", + null=True, blank=True, + ) + referrer = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="referred_orders", + null=True, blank=True, + help_text=_("The user whose referral_code was applied at checkout."), + ) + + gateway = models.CharField(_("gateway"), max_length=16, choices=Gateway.choices) + authority = models.CharField(_("authority"), max_length=100, blank=True, db_index=True) + ref_id = models.CharField(_("ref ID"), max_length=64, blank=True) + status = models.CharField( + _("status"), max_length=16, choices=OrderStatus.choices, default=OrderStatus.PENDING + ) + + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + paid_at = models.DateTimeField(_("paid at"), null=True, blank=True) + failed_at = models.DateTimeField(_("failed at"), null=True, blank=True) + last_error = models.CharField(_("last error"), max_length=255, blank=True) + + class Meta: + verbose_name = _("order") + verbose_name_plural = _("orders") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["student", "-created_at"]), + models.Index(fields=["course", "-created_at"]), + models.Index(fields=["status"]), + ] + + def __str__(self): + return f"Order#{self.pk} {self.student_id}→{self.course_id} {self.status}" + + @property + def is_paid(self) -> bool: + return self.status == OrderStatus.PAID + + def mark_paid(self, ref_id: str = ""): + self.status = OrderStatus.PAID + self.paid_at = timezone.now() + if ref_id: + self.ref_id = ref_id + self.save(update_fields=["status", "paid_at", "ref_id"]) + + def mark_failed(self, error: str = ""): + self.status = OrderStatus.FAILED + self.failed_at = timezone.now() + if error: + self.last_error = error[:255] + self.save(update_fields=["status", "failed_at", "last_error"]) + + +class Referral(models.Model): + """Settled referral attribution — created when a paid order had a referrer.""" + + referrer = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="referrals_given", + ) + buyer = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="referrals_received", + ) + order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name="referral") + course = models.ForeignKey("courses.Course", on_delete=models.PROTECT, related_name="referrals") + buyer_discount_toman = models.PositiveIntegerField(_("buyer discount (Toman)"), default=0) + referrer_credit_toman = models.PositiveIntegerField(_("referrer credit (Toman)"), default=0) + settled_at = models.DateTimeField(_("settled at"), auto_now_add=True) + + class Meta: + verbose_name = _("referral") + verbose_name_plural = _("referrals") + ordering = ["-settled_at"] + indexes = [ + models.Index(fields=["referrer", "-settled_at"]), + models.Index(fields=["buyer", "-settled_at"]), + ] + + def __str__(self): + return f"Referral<{self.referrer_id}→{self.buyer_id} order#{self.order_id}>" diff --git a/apps/payments/services/__init__.py b/apps/payments/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/services/gateway.py b/apps/payments/services/gateway.py new file mode 100644 index 0000000..20ed278 --- /dev/null +++ b/apps/payments/services/gateway.py @@ -0,0 +1,274 @@ +"""Payment gateway abstraction. + +Backends implement two operations: + + start(order, callback_url) -> redirect_url + Open a payment intent on the gateway and return the URL to redirect + the user's browser to. May raise PaymentGatewayError. + + verify(order) -> (ok: bool, ref_id: str | None, error: str | None) + Confirm the payment after the user has been bounced back to our + callback. The Order's `authority` field must already be populated. + +Two backends are bundled: + + ConsolePaymentBackend + Dev-only. `start()` returns a URL pointing back to our own callback + view with success query parameters, so the dev can click through and + complete the flow without a real gateway. `verify()` always succeeds. + + ZarinPalBackend + Production. Talks to ZarinPal v4 (sandbox or live, toggled by + ZARINPAL_SANDBOX). Lifted from the working Vitron-back implementation + with the cleanups identified in the design review (no hardcoded sandbox + URLs, explicit timeouts, no over-logging of merchant id / payloads). + +Selection is via the dotted path in `settings.PAYMENT_BACKEND`. +""" + +from __future__ import annotations + +import logging +from typing import Optional, Protocol, Tuple + +import requests +from django.conf import settings +from django.utils.module_loading import import_string + +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- exceptions + + +class PaymentGatewayError(Exception): + """Raised when a gateway call fails or returns an unexpected response.""" + + +# ----------------------------------------------------------------------------- protocol + + +class PaymentBackend(Protocol): + gateway_id: str + + def start(self, order, callback_url: str) -> str: ... + + def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]: ... + + +# ----------------------------------------------------------------------------- console (dev) + + +class ConsolePaymentBackend: + """Dev backend — fakes the gateway round-trip. + + `start()` returns the callback URL with a success token, so the user clicks + a link and lands on our callback view as if they'd successfully paid. + """ + + gateway_id = "console" + + def start(self, order, callback_url: str) -> str: + # Embed a fake authority + Status=OK so the callback succeeds when the + # browser is pointed here. The authority is what we'll match against + # when the callback runs, so we also store it on the order. + authority = f"CONSOLE-{order.pk}" + order.authority = authority + order.save(update_fields=["authority"]) + sep = "&" if "?" in callback_url else "?" + return f"{callback_url}{sep}Authority={authority}&Status=OK" + + def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]: + # Always succeed in dev. Pretend ZarinPal gave us a ref_id. + return True, f"console-ref-{order.pk}", None + + +# ----------------------------------------------------------------------------- ZarinPal + + +class ZarinPalBackend: + """Hand-rolled ZarinPal v4 client. + + Endpoints: + Request: POST {base}/pg/v4/payment/request.json + Verify: POST {base}/pg/v4/payment/verify.json + Redirect: {pg_base}/pg/StartPay/ + + Amounts are stored in **Toman** in our DB; ZarinPal expects **Rial**, so we + multiply by 10 on every API call. + """ + + gateway_id = "zarinpal" + TIMEOUT = 15 + + # API endpoints (sandbox vs production) + SANDBOX_API_BASE = "https://sandbox.zarinpal.com" + LIVE_API_BASE = "https://api.zarinpal.com" + SANDBOX_PG_BASE = "https://sandbox.zarinpal.com" + LIVE_PG_BASE = "https://www.zarinpal.com" + + def __init__(self): + self.merchant_id = settings.ZARINPAL_MERCHANT_ID + self.sandbox = bool(getattr(settings, "ZARINPAL_SANDBOX", True)) + if not self.merchant_id: + raise PaymentGatewayError("ZARINPAL_MERCHANT_ID is not configured") + + # -- internal helpers --------------------------------------------------- + + @property + def _api_base(self) -> str: + return self.SANDBOX_API_BASE if self.sandbox else self.LIVE_API_BASE + + @property + def _pg_base(self) -> str: + return self.SANDBOX_PG_BASE if self.sandbox else self.LIVE_PG_BASE + + def _post(self, path: str, payload: dict) -> dict: + url = f"{self._api_base}{path}" + try: + resp = requests.post(url, json=payload, timeout=self.TIMEOUT) + except requests.RequestException as e: + raise PaymentGatewayError(f"ZarinPal network error: {e}") from e + try: + data = resp.json() + except ValueError as e: + raise PaymentGatewayError( + f"ZarinPal non-JSON response (HTTP {resp.status_code})" + ) from e + if not (200 <= resp.status_code < 300): + raise PaymentGatewayError(f"ZarinPal HTTP {resp.status_code}: {data}") + return data + + # -- protocol ----------------------------------------------------------- + + def start(self, order, callback_url: str) -> str: + amount_rial = int(order.amount_toman) * 10 + payload = { + "merchant_id": self.merchant_id, + "amount": amount_rial, + "callback_url": callback_url, + "description": f"ilo · {order.course.title or 'course'} (#{order.pk})", + "metadata": { + "mobile": getattr(order.student, "phone_number", "") or "", + }, + } + data = self._post("/pg/v4/payment/request.json", payload) + body = data.get("data") or {} + if data.get("errors") or body.get("code") != 100 or not body.get("authority"): + errors = data.get("errors") or body + raise PaymentGatewayError(f"ZarinPal request failed: {errors}") + + authority = body["authority"] + order.authority = authority + order.save(update_fields=["authority"]) + return f"{self._pg_base}/pg/StartPay/{authority}" + + def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]: + if not order.authority: + return False, None, "Order has no authority to verify" + amount_rial = int(order.amount_toman) * 10 + payload = { + "merchant_id": self.merchant_id, + "amount": amount_rial, + "authority": order.authority, + } + try: + data = self._post("/pg/v4/payment/verify.json", payload) + except PaymentGatewayError as e: + return False, None, str(e) + + body = data.get("data") or {} + code = body.get("code") + # 100 = first-time success, 101 = already verified (treat as success) + if code in (100, 101): + return True, str(body.get("ref_id") or ""), None + + errors = data.get("errors") or body + return False, None, f"ZarinPal verify failed: {errors}" + + +# ----------------------------------------------------------------------------- IDPay + + +class IDPayBackend: + """Hand-rolled IDPay v1.1 client. + + Endpoints: + Create: POST {base}/v1.1/payment body: {order_id, amount, name, phone, desc, callback} + Verify: POST {base}/v1.1/payment/verify body: {id, order_id} + Redirect: response.link (returned by Create) + + Like ZarinPal, amounts go in **Rial** (Toman * 10). Sandbox toggled via the + `X-SANDBOX: 1` header rather than a separate URL. + """ + + gateway_id = "idpay" + TIMEOUT = 15 + BASE_URL = "https://api.idpay.ir" + + def __init__(self): + self.api_key = settings.IDPAY_API_KEY + self.sandbox = bool(getattr(settings, "IDPAY_SANDBOX", True)) + if not self.api_key: + raise PaymentGatewayError("IDPAY_API_KEY is not configured") + + def _headers(self): + h = {"X-API-KEY": self.api_key, "Content-Type": "application/json"} + if self.sandbox: + h["X-SANDBOX"] = "1" + return h + + def _post(self, path: str, payload: dict) -> dict: + url = f"{self.BASE_URL}{path}" + try: + resp = requests.post(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT) + except requests.RequestException as e: + raise PaymentGatewayError(f"IDPay network error: {e}") from e + try: + data = resp.json() + except ValueError as e: + raise PaymentGatewayError(f"IDPay non-JSON response (HTTP {resp.status_code})") from e + if not (200 <= resp.status_code < 300): + raise PaymentGatewayError(f"IDPay HTTP {resp.status_code}: {data}") + return data + + def start(self, order, callback_url: str) -> str: + amount_rial = int(order.amount_toman) * 10 + payload = { + "order_id": str(order.pk), + "amount": amount_rial, + "name": order.student.full_name or "", + "phone": order.student.phone_number or "", + "desc": f"ilo · {order.course.title or 'course'} (#{order.pk})", + "callback": callback_url, + } + data = self._post("/v1.1/payment", payload) + if not data.get("id") or not data.get("link"): + raise PaymentGatewayError(f"IDPay request failed: {data}") + order.authority = data["id"] + order.save(update_fields=["authority"]) + return data["link"] + + def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]: + if not order.authority: + return False, None, "Order has no authority to verify" + payload = {"id": order.authority, "order_id": str(order.pk)} + try: + data = self._post("/v1.1/payment/verify", payload) + except PaymentGatewayError as e: + return False, None, str(e) + status_code = data.get("status") + # 100 = first-time success, 101 = already verified + if status_code in (100, 101): + return True, str(data.get("track_id") or ""), None + return False, None, f"IDPay verify failed: {data}" + + +# ----------------------------------------------------------------------------- factory + + +_DEFAULT_BACKEND = "apps.payments.services.gateway.ConsolePaymentBackend" + + +def get_payment_backend() -> PaymentBackend: + return import_string(getattr(settings, "PAYMENT_BACKEND", _DEFAULT_BACKEND))() diff --git a/apps/payments/services/orders.py b/apps/payments/services/orders.py new file mode 100644 index 0000000..50e8013 --- /dev/null +++ b/apps/payments/services/orders.py @@ -0,0 +1,221 @@ +"""Order lifecycle helpers — create, complete, fail.""" + +from __future__ import annotations + +from typing import Optional + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from apps.enrollments.models import Enrollment + +from ..models import Gateway, Order, OrderStatus, PromoCode, Referral +from .gateway import PaymentGatewayError, get_payment_backend + + +class OrderError(Exception): + """Raised by start_checkout when an order can't be created.""" + + +def _split(amount_toman: int, commission_percent: int) -> tuple[int, int]: + fee = (amount_toman * commission_percent) // 100 + instructor_share = amount_toman - fee + return fee, instructor_share + + +def _resolve_discount(course, base_amount: int, *, promo_code: str | None, + referral_code: str | None, buyer): + """Return (discount_toman, promo_code_obj, referrer_user, referrer_credit_toman). + + Raises OrderError on validation failures.""" + from apps.accounts.models import User + + if promo_code and referral_code: + raise OrderError("Use either a promo code OR a referral code, not both.") + + if promo_code: + code = promo_code.strip().upper() + try: + pc = PromoCode.objects.get(code=code) + except PromoCode.DoesNotExist: + raise OrderError("Promo code not found.") + if not pc.is_valid_now(): + raise OrderError("Promo code is not currently valid.") + if not pc.applies_to_course(course): + raise OrderError("Promo code does not apply to this course.") + return pc.discount_for(base_amount), pc, None, 0 + + if referral_code: + code = referral_code.strip().upper() + try: + referrer = User.objects.get(referral_code=code) + except User.DoesNotExist: + raise OrderError("Referral code not found.") + if buyer is not None and referrer.id == buyer.id: + raise OrderError("You cannot apply your own referral code.") + buyer_pct = int(getattr(settings, "REFERRAL_BUYER_DISCOUNT_PERCENT", 10)) + ref_pct = int(getattr(settings, "REFERRAL_REFERRER_CREDIT_PERCENT", 10)) + buyer_discount = (base_amount * buyer_pct) // 100 + referrer_credit = ((base_amount - buyer_discount) * ref_pct) // 100 + return buyer_discount, None, referrer, referrer_credit + + return 0, None, None, 0 + + +def start_checkout(student, course, *, promo_code: str | None = None, + referral_code: str | None = None) -> tuple[Order, str]: + """Create a pending Order for `student` on `course` and return (order, redirect_url). + + Optionally applies a promo code or a referral code (mutually exclusive). + If the discount drives the amount to 0, the Order is created with + gateway=`free`, immediately marked paid, and the Enrollment is created — + `redirect_url` will then be the configured PAYMENT_SUCCESS_REDIRECT_URL with + `order_id` so the frontend can show the receipt. + """ + if course.is_free: + raise OrderError("Course is free; use /api/courses//enroll/ instead.") + + if Enrollment.objects.filter(student=student, course=course).exists(): + raise OrderError("Already enrolled in this course.") + + base_amount = int(course.price_toman or 0) + discount, pc_obj, referrer, _referrer_credit = _resolve_discount( + course, base_amount, promo_code=promo_code, referral_code=referral_code, buyer=student, + ) + final_amount = max(0, base_amount - discount) + + commission_percent = int(getattr(settings, "ILO_COMMISSION_PERCENT", 15)) + fee, instructor_share = _split(final_amount, commission_percent) + + if final_amount == 0: + # Discount/promo dropped the price to zero — skip the gateway entirely. + with transaction.atomic(): + order = Order.objects.create( + student=student, + course=course, + base_amount_toman=base_amount, + discount_amount_toman=discount, + amount_toman=0, + ilo_fee_toman=0, + instructor_share_toman=0, + commission_percent_snapshot=commission_percent, + gateway=Gateway.FREE, + status=OrderStatus.PAID, + paid_at=timezone.now(), + promo_code=pc_obj, + referrer=referrer, + ref_id="free-with-discount", + ) + Enrollment.objects.get_or_create(student=student, course=course) + if pc_obj: + PromoCode.objects.filter(pk=pc_obj.pk).update(used_count=pc_obj.used_count + 1) + if referrer: + Referral.objects.create( + referrer=referrer, buyer=student, order=order, course=course, + buyer_discount_toman=discount, + referrer_credit_toman=_referrer_credit, + ) + _record_paid_events(order, free_via_discount=True) + success_url = getattr(settings, "PAYMENT_SUCCESS_REDIRECT_URL", "/payment/success") + sep = "&" if "?" in success_url else "?" + return order, f"{success_url}{sep}order_id={order.pk}&ref_id={order.ref_id}" + + backend = get_payment_backend() + order = Order.objects.create( + student=student, + course=course, + base_amount_toman=base_amount, + discount_amount_toman=discount, + amount_toman=final_amount, + ilo_fee_toman=fee, + instructor_share_toman=instructor_share, + commission_percent_snapshot=commission_percent, + gateway=Gateway(backend.gateway_id), + status=OrderStatus.PENDING, + promo_code=pc_obj, + referrer=referrer, + ) + + callback_url = ( + f"{getattr(settings, 'PAYMENT_CALLBACK_BASE_URL', 'http://localhost:8000').rstrip('/')}" + f"/api/payments/callback/{backend.gateway_id}/?order_id={order.pk}" + ) + try: + redirect_url = backend.start(order, callback_url) + except PaymentGatewayError as e: + order.mark_failed(str(e)) + raise + + return order, redirect_url + + +def _record_paid_events(order: Order, *, free_via_discount: bool = False): + from apps.enrollments.models import ActivityKind + from apps.enrollments.services.events import record_event + + record_event( + course=order.course, + actor=order.student, + kind=ActivityKind.ENROLLED.value, + payload={ + "price_toman": order.amount_toman, + "via": "free_with_discount" if free_via_discount else "checkout", + "order_id": order.pk, + }, + ) + record_event( + course=order.course, + actor=order.student, + kind=ActivityKind.PAID.value, + payload={ + "order_id": order.pk, + "amount_toman": order.amount_toman, + "discount_toman": order.discount_amount_toman, + "instructor_share_toman": order.instructor_share_toman, + "ref_id": order.ref_id, + }, + ) + + +def complete_or_fail(order: Order, callback_status: str) -> tuple[bool, str]: + """Idempotently verify a callback. See views.GatewayCallbackView for context.""" + if order.status == OrderStatus.PAID: + return True, "already paid" + + if order.status not in (OrderStatus.PENDING,): + return False, f"order is in terminal state: {order.status}" + + if callback_status and callback_status.upper() != "OK": + order.mark_failed(f"Gateway status: {callback_status}") + return False, "user cancelled or gateway reported failure" + + backend = get_payment_backend() + ok, ref_id, error = backend.verify(order) + if not ok: + order.mark_failed(error or "verify failed") + return False, error or "verify failed" + + with transaction.atomic(): + order.mark_paid(ref_id=ref_id or "") + enrollment, created = Enrollment.objects.get_or_create( + student=order.student, course=order.course + ) + # Bump promo usage and settle referral exactly once per Order. + if order.promo_code_id: + PromoCode.objects.filter(pk=order.promo_code_id).update( + used_count=PromoCode.objects.get(pk=order.promo_code_id).used_count + 1 + ) + if order.referrer_id and not Referral.objects.filter(order=order).exists(): + ref_pct = int(getattr(settings, "REFERRAL_REFERRER_CREDIT_PERCENT", 10)) + Referral.objects.create( + referrer_id=order.referrer_id, + buyer=order.student, + order=order, + course=order.course, + buyer_discount_toman=order.discount_amount_toman, + referrer_credit_toman=(order.amount_toman * ref_pct) // 100, + ) + if created: + _record_paid_events(order) + return True, "paid" diff --git a/apps/payments/tests.py b/apps/payments/tests.py new file mode 100644 index 0000000..0b8bd71 --- /dev/null +++ b/apps/payments/tests.py @@ -0,0 +1,285 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.accounts.models import Role, TeacherProfile +from apps.courses.models import ( + Course, + Lesson, + LessonKind, + LessonStatus, + Section, +) +from apps.enrollments.models import Enrollment +from apps.payments.models import Gateway, Order, OrderStatus + +User = get_user_model() + +CONSOLE_PAYMENT = "apps.payments.services.gateway.ConsolePaymentBackend" + + +# --------------------------------------------------------------------------- helpers + +def _teacher(phone="09120000001", approved=True): + u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER) + TeacherProfile.objects.create(user=u, is_approved=approved) + return u + + +def _student(phone="09120000099"): + return User.objects.create_user(phone_number=phone, full_name="Student", role=Role.STUDENT) + + +def _paid_course(instructor, *, price=1_000_000, n_lessons=2, published=True): + c = Course.objects.create( + instructor=instructor, title="Pay Course", tagline="T", description="D", + level="beginner", language="fa", price_toman=price, + ) + if published: + c.publish() + sec = Section.objects.create(course=c, title="S1", order=0) + for i in range(n_lessons): + Lesson.objects.create( + section=sec, title=f"L{i+1}", kind=LessonKind.VIDEO, + video_url=f"https://e.com/{i}", status=LessonStatus.PUBLISHED, order=i, + ) + return c + + +def _free_course(instructor, *, published=True): + c = Course.objects.create( + instructor=instructor, title="Free", tagline="T", description="D", + level="beginner", language="fa", price_toman=None, + ) + if published: + c.publish() + return c + + +# --------------------------------------------------------------------------- order math + +class OrderMathTests(TestCase): + @override_settings(ILO_COMMISSION_PERCENT=15, PAYMENT_BACKEND=CONSOLE_PAYMENT) + def test_commission_split_at_15_percent(self): + from apps.payments.services.orders import start_checkout + + t = _teacher() + s = _student() + course = _paid_course(t, price=1_000_000) + order, redirect_url = start_checkout(s, course) + self.assertEqual(order.amount_toman, 1_000_000) + self.assertEqual(order.ilo_fee_toman, 150_000) + self.assertEqual(order.instructor_share_toman, 850_000) + self.assertEqual(order.commission_percent_snapshot, 15) + self.assertEqual(order.gateway, Gateway.CONSOLE) + self.assertEqual(order.status, OrderStatus.PENDING) + self.assertTrue(order.authority.startswith("CONSOLE-")) + self.assertIn("Authority=CONSOLE-", redirect_url) + self.assertIn("Status=OK", redirect_url) + + @override_settings(ILO_COMMISSION_PERCENT=20, PAYMENT_BACKEND=CONSOLE_PAYMENT) + def test_commission_uses_setting(self): + from apps.payments.services.orders import start_checkout + + t = _teacher() + s = _student() + course = _paid_course(t, price=500_000) + order, _ = start_checkout(s, course) + self.assertEqual(order.ilo_fee_toman, 100_000) + self.assertEqual(order.instructor_share_toman, 400_000) + + +# --------------------------------------------------------------------------- checkout endpoint + +@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT) +class CheckoutAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + self.s = _student() + self.paid = _paid_course(self.t, price=2_000_000) + self.free = _free_course(self.t) + + def test_anon_blocked(self): + r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/") + self.assertEqual(r.status_code, 401) + + def test_teacher_blocked(self): + self.client.force_authenticate(self.t) + r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/") + self.assertEqual(r.status_code, 403) + + def test_student_checkout_returns_redirect(self): + self.client.force_authenticate(self.s) + r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/") + self.assertEqual(r.status_code, 201, r.content) + body = r.json() + self.assertEqual(body["amount_toman"], 2_000_000) + self.assertEqual(body["gateway"], "console") + self.assertIn("Authority=CONSOLE-", body["redirect_url"]) + self.assertEqual(Order.objects.filter(student=self.s).count(), 1) + + def test_checkout_for_free_course_400(self): + self.client.force_authenticate(self.s) + r = self.client.post(f"/api/courses/{self.free.slug}/checkout/") + self.assertEqual(r.status_code, 400) + + def test_checkout_when_already_enrolled_400(self): + Enrollment.objects.create(student=self.s, course=self.paid) + self.client.force_authenticate(self.s) + r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/") + self.assertEqual(r.status_code, 400) + + def test_enroll_endpoint_returns_402_for_paid_course(self): + self.client.force_authenticate(self.s) + r = self.client.post(f"/api/courses/{self.paid.slug}/enroll/") + self.assertEqual(r.status_code, 402) + body = r.json() + self.assertIn("checkout_url", body) + self.assertEqual(body["price_toman"], 2_000_000) + + +# --------------------------------------------------------------------------- callback + +@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT, PAYMENT_SUCCESS_REDIRECT_URL="/p/ok", PAYMENT_FAILURE_REDIRECT_URL="/p/fail") +class CallbackAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + self.s = _student() + self.course = _paid_course(self.t, price=1_000_000) + + def _open_pending_order(self): + self.client.force_authenticate(self.s) + r = self.client.post(f"/api/courses/{self.course.slug}/checkout/") + order_id = r.json()["order_id"] + # The console redirect URL has Authority=CONSOLE-&Status=OK&order_id= + return Order.objects.get(pk=order_id) + + def test_full_flow_success(self): + order = self._open_pending_order() + r = self.client.get( + f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}" + ) + self.assertEqual(r.status_code, 302) + self.assertIn("/p/ok", r["Location"]) + order.refresh_from_db() + self.assertEqual(order.status, OrderStatus.PAID) + self.assertTrue( + Enrollment.objects.filter(student=self.s, course=self.course).exists() + ) + + def test_callback_idempotent(self): + order = self._open_pending_order() + r1 = self.client.get( + f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}" + ) + r2 = self.client.get( + f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}" + ) + self.assertEqual(r1.status_code, 302) + self.assertEqual(r2.status_code, 302) + # Still exactly one enrollment + self.assertEqual( + Enrollment.objects.filter(student=self.s, course=self.course).count(), 1 + ) + + def test_callback_user_cancel_marks_failed(self): + order = self._open_pending_order() + r = self.client.get( + f"/api/payments/callback/console/?order_id={order.pk}&Status=NOK&Authority={order.authority}" + ) + self.assertEqual(r.status_code, 302) + self.assertIn("/p/fail", r["Location"]) + order.refresh_from_db() + self.assertEqual(order.status, OrderStatus.FAILED) + self.assertFalse( + Enrollment.objects.filter(student=self.s, course=self.course).exists() + ) + + def test_callback_unknown_order(self): + r = self.client.get( + "/api/payments/callback/console/?order_id=999999&Status=OK&Authority=X" + ) + self.assertEqual(r.status_code, 302) + self.assertIn("/p/fail", r["Location"]) + + +# --------------------------------------------------------------------------- order lists + +@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT) +class OrderListAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + self.s = _student() + self.course = _paid_course(self.t, price=1_500_000) + + def test_my_orders_lists_own(self): + # complete a full flow first + self.client.force_authenticate(self.s) + co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json() + self.client.get( + f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}" + ) + r = self.client.get("/api/me/orders/") + self.assertEqual(r.status_code, 200) + body = r.json() + self.assertEqual(len(body), 1) + self.assertEqual(body[0]["status"], "paid") + self.assertEqual(body[0]["amount_toman"], 1_500_000) + self.assertEqual(body[0]["course_slug"], self.course.slug) + + def test_instructor_sales(self): + # student buys + self.client.force_authenticate(self.s) + co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json() + self.client.get( + f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}" + ) + # teacher sees the sale + self.client.force_authenticate(self.t) + r = self.client.get("/api/instructor/orders/?status=paid") + self.assertEqual(r.status_code, 200) + body = r.json() + self.assertEqual(len(body), 1) + self.assertEqual(body[0]["amount_toman"], 1_500_000) + self.assertEqual(body[0]["ilo_fee_toman"], 225_000) # 15% + self.assertEqual(body[0]["instructor_share_toman"], 1_275_000) + self.assertEqual(body[0]["student_id"], self.s.id) + + def test_student_blocked_from_instructor_sales(self): + self.client.force_authenticate(self.s) + r = self.client.get("/api/instructor/orders/") + self.assertEqual(r.status_code, 403) + + +@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT) +class OrderDetailAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + self.s = _student() + self.course = _paid_course(self.t, price=500_000) + self.client.force_authenticate(self.s) + co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json() + self.order_id = co["order_id"] + self.client.get( + f"/api/payments/callback/console/?order_id={self.order_id}&Status=OK&Authority=CONSOLE-{self.order_id}" + ) + + def test_get_my_order_detail(self): + r = self.client.get(f"/api/me/orders/{self.order_id}/") + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertEqual(body["id"], self.order_id) + self.assertEqual(body["status"], "paid") + self.assertEqual(body["amount_toman"], 500_000) + self.assertTrue(body["ref_id"]) + + def test_cannot_get_others_order(self): + other = _student(phone="09120000022") + self.client.force_authenticate(other) + r = self.client.get(f"/api/me/orders/{self.order_id}/") + self.assertEqual(r.status_code, 404) diff --git a/apps/payments/tests_phase6c.py b/apps/payments/tests_phase6c.py new file mode 100644 index 0000000..d969c76 --- /dev/null +++ b/apps/payments/tests_phase6c.py @@ -0,0 +1,392 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import Role, TeacherProfile +from apps.courses.models import Course, Lesson, LessonKind, LessonStatus, Section +from apps.enrollments.models import Enrollment +from apps.payments.models import Order, OrderStatus, PromoCode, Referral + +User = get_user_model() + +CONSOLE = "apps.payments.services.gateway.ConsolePaymentBackend" + + +def _teacher(phone="09120000001"): + u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER) + TeacherProfile.objects.create(user=u, is_approved=True) + return u + + +def _student(phone="09120000099"): + return User.objects.create_user(phone_number=phone, full_name="Student", role=Role.STUDENT) + + +def _paid_course(instructor, *, price=1_000_000): + c = Course.objects.create( + instructor=instructor, title="C", tagline="T", description="D", + level="beginner", language="fa", price_toman=price, + ) + c.publish() + sec = Section.objects.create(course=c, title="S") + Lesson.objects.create(section=sec, title="L", kind=LessonKind.VIDEO, video_url="https://e.com/x", status=LessonStatus.PUBLISHED) + return c + + +# ==================================================================== referral code + +class ReferralCodeAutoTests(TestCase): + def test_referral_code_auto_generated_on_save(self): + u = User.objects.create_user(phone_number="09120000010", full_name="X", role=Role.STUDENT) + self.assertEqual(len(u.referral_code), 6) + u2 = User.objects.create_user(phone_number="09120000011", full_name="Y", role=Role.STUDENT) + self.assertNotEqual(u.referral_code, u2.referral_code) + + +# ==================================================================== promo codes + +@override_settings(PAYMENT_BACKEND=CONSOLE) +class PromoCodeCheckoutTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + self.s = _student() + self.course = _paid_course(self.t, price=1_000_000) + + def test_percent_promo_applied(self): + PromoCode.objects.create(code="WELCOME10", discount_percent=10, is_active=True) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "welcome10"}, format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + order = Order.objects.get(pk=r.json()["order_id"]) + self.assertEqual(order.base_amount_toman, 1_000_000) + self.assertEqual(order.discount_amount_toman, 100_000) + self.assertEqual(order.amount_toman, 900_000) + self.assertEqual(order.promo_code.code, "WELCOME10") + + def test_fixed_promo_applied(self): + PromoCode.objects.create(code="MINUS200K", discount_amount_toman=200_000, is_active=True) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "MINUS200K"}, format="json", + ) + self.assertEqual(r.status_code, 201) + order = Order.objects.get(pk=r.json()["order_id"]) + self.assertEqual(order.amount_toman, 800_000) + + def test_invalid_promo_400(self): + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "NOTREAL"}, format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_expired_promo_400(self): + PromoCode.objects.create( + code="OLD", discount_percent=10, + valid_until=timezone.now() - timedelta(days=1), + ) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "OLD"}, format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_max_uses_exhausted(self): + PromoCode.objects.create(code="ONCE", discount_percent=10, max_uses=1, used_count=1) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "ONCE"}, format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_promo_scoped_to_other_course_400(self): + other = _paid_course(self.t, price=500_000) + other.slug = "other"; other.save() + PromoCode.objects.create(code="OC10", discount_percent=10, course=other) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "OC10"}, format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_promo_used_count_bumped_on_paid(self): + pc = PromoCode.objects.create(code="W10", discount_percent=10) + self.client.force_authenticate(self.s) + co = self.client.post(f"/api/courses/{self.course.slug}/checkout/", {"promo_code": "W10"}, format="json").json() + self.client.get( + f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}" + ) + pc.refresh_from_db() + self.assertEqual(pc.used_count, 1) + + def test_full_discount_skips_gateway(self): + PromoCode.objects.create(code="FREE100", discount_percent=100) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "FREE100"}, format="json", + ) + self.assertEqual(r.status_code, 201) + body = r.json() + self.assertEqual(body["amount_toman"], 0) + self.assertEqual(body["gateway"], "free") + order = Order.objects.get(pk=body["order_id"]) + self.assertEqual(order.status, OrderStatus.PAID) + self.assertTrue( + Enrollment.objects.filter(student=self.s, course=self.course).exists() + ) + + +# ==================================================================== referral codes + +@override_settings(PAYMENT_BACKEND=CONSOLE, REFERRAL_BUYER_DISCOUNT_PERCENT=10, REFERRAL_REFERRER_CREDIT_PERCENT=10) +class ReferralCheckoutTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + self.s = _student() + self.referrer = _student(phone="09120000050") + self.course = _paid_course(self.t, price=1_000_000) + + def test_referral_applies_buyer_discount(self): + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"referral_code": self.referrer.referral_code}, format="json", + ) + self.assertEqual(r.status_code, 201, r.content) + order = Order.objects.get(pk=r.json()["order_id"]) + self.assertEqual(order.discount_amount_toman, 100_000) + self.assertEqual(order.amount_toman, 900_000) + self.assertEqual(order.referrer_id, self.referrer.id) + + def test_self_referral_rejected(self): + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"referral_code": self.s.referral_code}, format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_promo_and_referral_together_rejected(self): + PromoCode.objects.create(code="X", discount_percent=10) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"promo_code": "X", "referral_code": self.referrer.referral_code}, format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_referral_row_created_on_paid(self): + self.client.force_authenticate(self.s) + co = self.client.post( + f"/api/courses/{self.course.slug}/checkout/", + {"referral_code": self.referrer.referral_code}, format="json", + ).json() + self.client.get( + f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}" + ) + ref = Referral.objects.get(order_id=co["order_id"]) + self.assertEqual(ref.referrer_id, self.referrer.id) + self.assertEqual(ref.buyer_id, self.s.id) + self.assertEqual(ref.buyer_discount_toman, 100_000) + # 10% of 900_000 = 90_000 + self.assertEqual(ref.referrer_credit_toman, 90_000) + + +# ==================================================================== preview + +@override_settings(PAYMENT_BACKEND=CONSOLE) +class CheckoutPreviewTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + self.s = _student() + self.course = _paid_course(self.t, price=1_000_000) + + def test_preview_with_promo_returns_discount(self): + PromoCode.objects.create(code="W10", discount_percent=10) + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/preview/", + {"promo_code": "W10"}, format="json", + ) + self.assertEqual(r.status_code, 200, r.content) + body = r.json() + self.assertEqual(body["base_amount_toman"], 1_000_000) + self.assertEqual(body["discount_amount_toman"], 100_000) + self.assertEqual(body["final_amount_toman"], 900_000) + self.assertEqual(body["promo_code_applied"], "W10") + # Preview must NOT have created an Order + self.assertFalse(Order.objects.exists()) + + def test_preview_with_invalid_promo_400(self): + self.client.force_authenticate(self.s) + r = self.client.post( + f"/api/courses/{self.course.slug}/checkout/preview/", + {"promo_code": "BAD"}, format="json", + ) + self.assertEqual(r.status_code, 400) + + +# ==================================================================== discount countdown + +class DiscountCountdownTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + + def test_discount_active_when_in_window(self): + c = Course.objects.create( + instructor=self.t, title="C", tagline="", description="", + level="beginner", language="fa", + price_toman=80, original_price_toman=100, + discount_starts_at=timezone.now() - timedelta(hours=1), + discount_ends_at=timezone.now() + timedelta(hours=1), + ) + c.publish() + r = self.client.get(f"/api/courses/{c.slug}/") + body = r.json() + self.assertTrue(body["discount_active"]) + self.assertIsNotNone(body["discount_ends_at"]) + + def test_discount_inactive_after_window(self): + c = Course.objects.create( + instructor=self.t, title="C2", tagline="", description="", + level="beginner", language="fa", + price_toman=80, original_price_toman=100, + discount_ends_at=timezone.now() - timedelta(hours=1), + ) + c.publish() + r = self.client.get(f"/api/courses/{c.slug}/") + self.assertFalse(r.json()["discount_active"]) + + def test_discount_inactive_when_no_original_price(self): + c = Course.objects.create( + instructor=self.t, title="C3", tagline="", description="", + level="beginner", language="fa", + price_toman=100, + ) + c.publish() + r = self.client.get(f"/api/courses/{c.slug}/") + self.assertFalse(r.json()["discount_active"]) + + +# ==================================================================== featured instructors filter + +class FeaturedInstructorsTests(TestCase): + def test_is_featured_filter(self): + client = APIClient() + a = _teacher(phone="09120000001") + b = _teacher(phone="09120000002") + a.teacher_profile.is_featured = True + a.teacher_profile.save() + r = client.get("/api/instructors/?is_featured=true") + ids = [row["id"] for row in r.json()] + self.assertIn(a.id, ids) + self.assertNotIn(b.id, ids) + + +# ==================================================================== global search + +class GlobalSearchTests(TestCase): + def setUp(self): + self.client = APIClient() + self.t = _teacher() + Course.objects.create( + instructor=self.t, title="Python Basics", tagline="", description="", + level="beginner", language="fa", price_toman=None, + ).publish() + Course.objects.create( + instructor=self.t, title="Persian Typography", tagline="", description="", + level="beginner", language="fa", + ).publish() + + def test_missing_q_400(self): + r = self.client.get("/api/search/") + self.assertEqual(r.status_code, 400) + + def test_search_courses_default(self): + r = self.client.get("/api/search/?q=Python") + self.assertEqual(r.status_code, 200) + body = r.json() + self.assertIn("courses", body) + titles = [c["title"] for c in body["courses"]] + self.assertIn("Python Basics", titles) + + def test_search_instructors_only(self): + r = self.client.get("/api/search/?q=Teacher&types=instructors") + body = r.json() + self.assertNotIn("courses", body) + self.assertIn("instructors", body) + + +# ==================================================================== IDPay backend + +class IDPayBackendUnitTests(TestCase): + """Unit-test IDPay's start/verify with mocked HTTP — we can't reach api.idpay.ir from dev.""" + + @override_settings(IDPAY_API_KEY="fake-key", IDPAY_SANDBOX=True) + def test_start_stores_authority_and_returns_link(self): + from unittest.mock import patch + + from apps.payments.services.gateway import IDPayBackend + + t = _teacher() + s = _student() + course = _paid_course(t, price=1000) + order = Order.objects.create( + student=s, course=course, + base_amount_toman=1000, amount_toman=1000, + gateway="idpay", status=OrderStatus.PENDING, + ) + with patch("apps.payments.services.gateway.requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"id": "FAKE-AUTH", "link": "https://idpay.ir/p/ws/FAKE-AUTH"} + backend = IDPayBackend() + redirect = backend.start(order, "https://example/cb") + order.refresh_from_db() + self.assertEqual(order.authority, "FAKE-AUTH") + self.assertEqual(redirect, "https://idpay.ir/p/ws/FAKE-AUTH") + + @override_settings(IDPAY_API_KEY="fake-key", IDPAY_SANDBOX=True) + def test_verify_success(self): + from unittest.mock import patch + + from apps.payments.services.gateway import IDPayBackend + + t = _teacher() + s = _student() + course = _paid_course(t, price=1000) + order = Order.objects.create( + student=s, course=course, + base_amount_toman=1000, amount_toman=1000, + gateway="idpay", status=OrderStatus.PENDING, authority="AUTH-1", + ) + with patch("apps.payments.services.gateway.requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"status": 100, "track_id": "TRACK-1"} + backend = IDPayBackend() + ok, ref_id, err = backend.verify(order) + self.assertTrue(ok) + self.assertEqual(ref_id, "TRACK-1") + self.assertIsNone(err) + + @override_settings(IDPAY_API_KEY="") + def test_init_without_key_fails(self): + from apps.payments.services.gateway import IDPayBackend, PaymentGatewayError + + with self.assertRaises(PaymentGatewayError): + IDPayBackend() diff --git a/design/Ilo Auth.html b/design/Ilo Auth.html new file mode 100644 index 0000000..4bedcc9 --- /dev/null +++ b/design/Ilo Auth.html @@ -0,0 +1,625 @@ + + + + + + ایلو · ورود و ثبت‌نام + + + + + + + + + + + + +
+ + + + diff --git a/design/Ilo Checkout.html b/design/Ilo Checkout.html new file mode 100644 index 0000000..7292e20 --- /dev/null +++ b/design/Ilo Checkout.html @@ -0,0 +1,608 @@ + + + + + + پرداخت — ایلو + + + + + + + + + + + + +
+ + + + + diff --git a/design/Ilo Course Page.html b/design/Ilo Course Page.html new file mode 100644 index 0000000..9a782de --- /dev/null +++ b/design/Ilo Course Page.html @@ -0,0 +1,521 @@ + + + + + + مبانی طراحی رابط کاربری — آرش پاکدل · ایلو + + + + + + + + + + + + +
+ + + + + diff --git a/design/Ilo Instructor Panel-print.html b/design/Ilo Instructor Panel-print.html new file mode 100644 index 0000000..3487ba9 --- /dev/null +++ b/design/Ilo Instructor Panel-print.html @@ -0,0 +1,170 @@ + + + + + + ایلو · پنل مدرس · چاپ + + + + + + + + + + + + +
+ + + + + + + + + + + + diff --git a/design/Ilo Instructor Panel.html b/design/Ilo Instructor Panel.html new file mode 100644 index 0000000..6ca6907 --- /dev/null +++ b/design/Ilo Instructor Panel.html @@ -0,0 +1,170 @@ + + + + + + ایلو · پنل مدرس + + + + + + + + + + +
+ + + + + + + + + + + + diff --git a/design/Ilo Landing.html b/design/Ilo Landing.html new file mode 100644 index 0000000..95e8d28 --- /dev/null +++ b/design/Ilo Landing.html @@ -0,0 +1,740 @@ + + + + + + ایلو — یاد بگیر، رشد کن + + + + + + + + + + + + +
+ + + + + diff --git a/design/Ilo Student.html b/design/Ilo Student.html new file mode 100644 index 0000000..92b21d8 --- /dev/null +++ b/design/Ilo Student.html @@ -0,0 +1,80 @@ + + + + + + ایلو · دانشجو + + + + + + + + + + +
+ + + + + + + + + diff --git a/design/data.jsx b/design/data.jsx new file mode 100644 index 0000000..d0c66d4 --- /dev/null +++ b/design/data.jsx @@ -0,0 +1,113 @@ +// All static fixture data for the prototype, in Persian. +// Names are realistic Iranian names; courses are realistic teacher topics. + +const NAV = [ + { id: 'home', fa: 'خانه', en: 'Home', icon: 'home' }, + { id: 'courses', fa: 'دوره‌ها', en: 'Courses', icon: 'book', badge: '۳' }, + { id: 'students', fa: 'دانشجویان', en: 'Students', icon: 'users' }, + { id: 'live', fa: 'کلاس زنده', en: 'Live', icon: 'video' }, + { id: 'quiz', fa: 'آزمون‌ها', en: 'Quizzes', icon: 'quiz' }, + { id: 'cert', fa: 'گواهی‌نامه', en: 'Certificates', icon: 'cert' }, + { id: 'chat', fa: 'گفت‌وگو', en: 'Chat', icon: 'chat', badge: '۷' }, +]; +const NAV_GROW = [ + { id: 'site', fa: 'سایت من', en: 'My Site', icon: 'store' }, + { id: 'mkt', fa: 'بازاریابی', en: 'Marketing', icon: 'megaphone' }, + { id: 'analytics',fa: 'تحلیل‌ها', en: 'Analytics', icon: 'chart' }, +]; + +// dashboard KPIs +const KPIS = [ + { id: 'rev', label: 'درآمد این ماه', value: '۲۸٬۴۵۰٬۰۰۰', unit: 'تومان', delta: '+۱۲٪', good: true, spark: [3,4,4,5,6,5,7,8,7,9,10,12] }, + { id: 'students', label: 'دانشجوی فعال', value: '۱٬۲۸۴', unit: 'نفر', delta: '+۴۸', good: true, spark: [4,4,5,6,6,7,8,8,9,10,11,11] }, + { id: 'progress', label: 'میانگین پیشرفت', value: '۶۸', unit: '٪', delta: '+۳٪', good: true, spark: [5,5,6,6,6,7,7,7,8,8,8,9] }, + { id: 'rating', label: 'رضایت دانشجویان', value: '۴٫۸', unit: 'از ۵', delta: '+۰٫۲', good: true, spark: [7,7,8,8,8,9,9,9,9,9,10,10] }, +]; + +// course list (instructor's catalog) +const COURSES = [ + { id: 'c1', title: 'مبانی طراحی رابط کاربری', titleEn: 'UI Design Fundamentals', + students: 412, lessons: 38, hours: 12.5, status: 'published', progress: 100, + revenue: '۱۸٬۲۰۰٬۰۰۰', price: '۲٬۴۵۰٬۰۰۰', rating: 4.9, color: 'oklch(0.78 0.12 40)' }, + { id: 'c2', title: 'تایپوگرافی فارسی در وب', titleEn: 'Persian Web Typography', + students: 287, lessons: 24, hours: 8.0, status: 'published', progress: 100, + revenue: '۹٬۸۵۰٬۰۰۰', price: '۱٬۸۰۰٬۰۰۰', rating: 4.7, color: 'oklch(0.74 0.12 200)' }, + { id: 'c3', title: 'کارگاه فیگما برای مبتدیان', titleEn: 'Figma Workshop for Beginners', + students: 0, lessons: 16, hours: 4.5, status: 'draft', progress: 64, + revenue: '—', price: '—', rating: null, color: 'oklch(0.78 0.10 140)' }, +]; + +// learning path / lessons for the course-builder +const MODULES = [ + { id: 'm1', title: 'پیش‌گفتار و آشنایی', expanded: true, lessons: [ + { id: 'l1', title: 'خوش‌آمدگویی به دوره', type: 'video', duration: '۳:۲۰', status: 'published', preview: true }, + { id: 'l2', title: 'پیش‌نیازها و ابزارها', type: 'video', duration: '۸:۴۵', status: 'published' }, + { id: 'l3', title: 'منابع و فایل‌های دوره', type: 'pdf', duration: '۵ صفحه', status: 'published' }, + ]}, + { id: 'm2', title: 'اصول طراحی بصری', expanded: true, lessons: [ + { id: 'l4', title: 'سلسله‌مراتب بصری', type: 'video', duration: '۱۴:۲۰', status: 'published' }, + { id: 'l5', title: 'تئوری رنگ کاربردی', type: 'video', duration: '۱۸:۰۵', status: 'published' }, + { id: 'l6', title: 'تمرین: پالت رنگ شخصی', type: 'task', duration: '۳۰ دقیقه', status: 'published' }, + { id: 'l7', title: 'گرید و سیستم لی‌اوت', type: 'video', duration: '۱۶:۴۰', status: 'draft' }, + { id: 'l8', title: 'آزمون فصل دوم', type: 'quiz', duration: '۱۰ سؤال', status: 'draft' }, + ]}, + { id: 'm3', title: 'تایپوگرافی', expanded: false, lessons: [ + { id: 'l9', title: 'انتخاب فونت مناسب', type: 'video', duration: '۱۲:۱۰', status: 'published' }, + { id: 'l10', title: 'تنظیم ارتفاع خط و فاصله', type: 'video', duration: '۹:۳۰', status: 'published' }, + { id: 'l11', title: 'تایپوگرافی فارسی', type: 'video', duration: '۱۵:۲۲', status: 'draft' }, + ]}, + { id: 'm4', title: 'کامپوننت‌ها و الگوها', expanded: false, lessons: [ + { id: 'l12', title: 'دکمه‌ها و فرم‌ها', type: 'video', duration: '۲۰:۰۰', status: 'draft' }, + { id: 'l13', title: 'ناوبری و منو', type: 'video', duration: '۱۸:۱۵', status: 'draft' }, + ]}, + { id: 'm5', title: 'پروژه پایانی', expanded: false, lessons: [ + { id: 'l14', title: 'بریف پروژه', type: 'pdf', duration: '۸ صفحه', status: 'draft' }, + { id: 'l15', title: 'ارائه و دریافت گواهی', type: 'task', duration: '—', status: 'draft' }, + ]}, +]; + +// students / CRM list +const STUDENT_COLORS = [ + 'oklch(0.72 0.10 30)', 'oklch(0.72 0.10 80)', 'oklch(0.72 0.10 140)', + 'oklch(0.72 0.10 200)', 'oklch(0.72 0.10 260)', 'oklch(0.72 0.10 320)', +]; + +const STUDENTS = [ + { name: 'سارا محمدی', initials: 'س م', course: 'مبانی طراحی رابط کاربری', progress: 92, lastActive: '۲ ساعت پیش', joined: '۱۴۰۳/۰۹/۱۲', status: 'active', tag: 'پروموتر', spent: '۲٬۴۵۰٬۰۰۰' }, + { name: 'علی رضایی', initials: 'ع ر', course: 'تایپوگرافی فارسی در وب', progress: 64, lastActive: 'دیروز', joined: '۱۴۰۳/۰۸/۲۰', status: 'active', tag: 'فعال', spent: '۱٬۸۰۰٬۰۰۰' }, + { name: 'نگار حسینی', initials: 'ن ح', course: 'مبانی طراحی رابط کاربری', progress: 100,lastActive: '۳ روز پیش', joined: '۱۴۰۳/۰۷/۰۵', status: 'completed', tag: 'فارغ‌التحصیل', spent: '۲٬۴۵۰٬۰۰۰' }, + { name: 'محمد احمدی', initials: 'م ا', course: 'مبانی طراحی رابط کاربری', progress: 28, lastActive: '۸ روز پیش', joined: '۱۴۰۳/۰۹/۲۸', status: 'at-risk', tag: 'ساکت', spent: '۲٬۴۵۰٬۰۰۰' }, + { name: 'مریم کریمی', initials: 'م ک', course: 'تایپوگرافی فارسی در وب', progress: 78, lastActive: 'امروز', joined: '۱۴۰۳/۰۸/۰۸', status: 'active', tag: 'پروموتر', spent: '۱٬۸۰۰٬۰۰۰' }, + { name: 'امیر صالحی', initials: 'ا ص', course: 'مبانی طراحی رابط کاربری', progress: 45, lastActive: 'دیروز', joined: '۱۴۰۳/۰۹/۲۲', status: 'active', tag: 'فعال', spent: '۲٬۴۵۰٬۰۰۰' }, + { name: 'زهرا اکبری', initials: 'ز ا', course: 'تایپوگرافی فارسی در وب', progress: 12, lastActive: '۱۴ روز پیش',joined: '۱۴۰۳/۰۹/۰۲', status: 'at-risk', tag: 'ساکت', spent: '۱٬۸۰۰٬۰۰۰' }, + { name: 'حسین مرادی', initials: 'ح م', course: 'مبانی طراحی رابط کاربری', progress: 86, lastActive: '۵ ساعت پیش', joined: '۱۴۰۳/۰۸/۱۵', status: 'active', tag: 'پروموتر', spent: '۲٬۴۵۰٬۰۰۰' }, + { name: 'فاطمه نوری', initials: 'ف ن', course: 'مبانی طراحی رابط کاربری', progress: 54, lastActive: '۲ روز پیش', joined: '۱۴۰۳/۰۹/۱۸', status: 'active', tag: 'فعال', spent: '۲٬۴۵۰٬۰۰۰' }, + { name: 'رضا قاسمی', initials: 'ر ق', course: 'تایپوگرافی فارسی در وب', progress: 100,lastActive: '۱ هفته پیش',joined: '۱۴۰۳/۰۷/۲۰', status: 'completed', tag: 'فارغ‌التحصیل', spent: '۱٬۸۰۰٬۰۰۰' }, + { name: 'الهام شریفی', initials: 'ا ش', course: 'مبانی طراحی رابط کاربری', progress: 70, lastActive: 'دیروز', joined: '۱۴۰۳/۰۸/۲۵', status: 'active', tag: 'فعال', spent: '۲٬۴۵۰٬۰۰۰' }, + { name: 'پوریا تقوی', initials: 'پ ت', course: 'مبانی طراحی رابط کاربری', progress: 8, lastActive: '۲۱ روز پیش',joined: '۱۴۰۳/۰۹/۰۵', status: 'at-risk', tag: 'خطر ریزش', spent: '۲٬۴۵۰٬۰۰۰' }, +].map((s, i) => ({ ...s, id: 's' + (i + 1), color: STUDENT_COLORS[i % STUDENT_COLORS.length] })); + +// timeline / activity feed for dashboard +const ACTIVITY = [ + { id: 'a1', t: '۹ دقیقه پیش', who: 'سارا محمدی', did: 'درس «سلسله‌مراتب بصری» را تکمیل کرد', course: 'مبانی طراحی', kind: 'lesson', color: 'oklch(0.72 0.10 30)' }, + { id: 'a2', t: '۲۲ دقیقه پیش', who: 'علی رضایی', did: 'پرسشی در گفت‌وگوی دوره مطرح کرد', course: 'تایپوگرافی فارسی', kind: 'chat', color: 'oklch(0.72 0.10 80)' }, + { id: 'a3', t: '۱ ساعت پیش', who: 'نگار حسینی', did: 'گواهی پایان دوره دریافت کرد 🎉', course: 'مبانی طراحی', kind: 'cert', color: 'oklch(0.72 0.10 140)' }, + { id: 'a4', t: '۲ ساعت پیش', who: 'حسین مرادی', did: 'دوره را خریداری کرد', course: 'مبانی طراحی', kind: 'sale', color: 'oklch(0.72 0.10 200)' }, + { id: 'a5', t: '۳ ساعت پیش', who: 'مریم کریمی', did: 'به آزمون فصل دوم نمره ۹۲ گرفت', course: 'تایپوگرافی فارسی', kind: 'quiz', color: 'oklch(0.72 0.10 260)' }, +]; + +// upcoming live sessions +const LIVE = [ + { id: 'lv1', title: 'پرسش و پاسخ هفتگی', course: 'مبانی طراحی رابط کاربری', at: 'پنج‌شنبه، ۱۹:۰۰', registered: 84, soon: true }, + { id: 'lv2', title: 'کارگاه عملی فیگما', course: 'کارگاه فیگما', at: 'جمعه، ۱۰:۳۰', registered: 32, soon: false }, +]; + +// inbox snippets +const INBOX = [ + { id: 'i1', name: 'پوریا تقوی', msg: 'سلام استاد، ویدیوی درس سوم باز نمی‌شود', t: '۵ د', unread: true, color: 'oklch(0.72 0.10 30)' }, + { id: 'i2', name: 'مریم کریمی', msg: 'ممنون از پاسختون! یه سوال دیگه...', t: '۴۲ د', unread: true, color: 'oklch(0.72 0.10 260)' }, + { id: 'i3', name: 'علی رضایی', msg: 'فایل تمرین رو فرستادم، لطفاً نگاه کنید', t: '۲ س', unread: true, color: 'oklch(0.72 0.10 80)' }, + { id: 'i4', name: 'فاطمه نوری', msg: 'تشکر بابت بازخورد دقیق ❤️', t: 'دیروز', unread: false, color: 'oklch(0.72 0.10 320)' }, +]; + +window.DATA = { NAV, NAV_GROW, KPIS, COURSES, MODULES, STUDENTS, ACTIVITY, LIVE, INBOX }; diff --git a/design/icons.jsx b/design/icons.jsx new file mode 100644 index 0000000..75b226d --- /dev/null +++ b/design/icons.jsx @@ -0,0 +1,64 @@ +// Stroke-style line icons (24px viewBox), rendered via currentColor. +// Friendly weight: 1.6px stroke, round caps, round joins. +const Ic = ({ d, size = 18, fill, stroke = "currentColor", sw = 1.6, children, ...rest }) => ( + +); + +const Icons = { + home: (p) => , + book: (p) => , + users: (p) => , + video: (p) => , + quiz: (p) => , + chat: (p) => , + cert: (p) => , + chart: (p) => , + store: (p) => , + megaphone:(p) => , + cog: (p) => , + search: (p) => , + bell: (p) => , + plus: (p) => , + chevron: (p) => , + check: (p) => , + trend: (p) => , + eye: (p) => , + filter: (p) => , + sort: (p) => , + more: (p) => , + drag: (p) => , + play: (p) => , + file: (p) => , + filetext:(p) => , + pdf: (p) => , + audio: (p) => , + link: (p) => , + mail: (p) => , + phone: (p) => , + star: (p) => , + calendar:(p) => , + clock: (p) => , + upload: (p) => , + edit: (p) => , + trash: (p) => , + copy: (p) => , + globe: (p) => , + send: (p) => , + flame: (p) => , + trophy: (p) => , +}; + +// shortcut wrapper +const Icon = ({ name, ...rest }) => { + const C = Icons[name]; + return C ? : null; +}; + +window.Icons = Icons; +window.Icon = Icon; diff --git a/design/screen-courses.jsx b/design/screen-courses.jsx new file mode 100644 index 0000000..70e0a22 --- /dev/null +++ b/design/screen-courses.jsx @@ -0,0 +1,283 @@ +// Course Builder screen — module/lesson tree with learning path. + +const TYPE_META = { + video: { ic: 'play', color: 'oklch(0.72 0.10 30)', fa: 'ویدیو' }, + pdf: { ic: 'pdf', color: 'oklch(0.72 0.10 80)', fa: 'PDF' }, + audio: { ic: 'audio', color: 'oklch(0.72 0.10 200)', fa: 'صوت' }, + task: { ic: 'edit', color: 'oklch(0.72 0.10 140)', fa: 'تمرین' }, + quiz: { ic: 'quiz', color: 'oklch(0.72 0.10 260)', fa: 'آزمون' }, +}; + +function LessonRow({ lesson, selected, onSelect }) { + const m = TYPE_META[lesson.type]; + return ( +
onSelect(lesson.id)} + style={{ + display: 'flex', alignItems: 'center', gap: 12, + padding: '10px 14px', + borderRadius: 'var(--r-sm)', + background: selected ? 'var(--accent-soft)' : 'transparent', + border: '1px solid ' + (selected ? 'transparent' : 'transparent'), + cursor: 'pointer', + transition: 'background .12s', + }} + onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = 'var(--surface-2)'; }} + onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }} + > + +
+ +
+
+
+ {lesson.title} +
+
+ {m.fa} · {lesson.duration} + {lesson.preview && · پیش‌نمایش رایگان} +
+
+ {lesson.status === 'draft' + ? پیش‌نویس + : + } +
+ ); +} + +function ModuleBlock({ mod, selectedLesson, onSelect, onToggle }) { + const lessonCount = mod.lessons.length; + const totalMin = mod.lessons.reduce((s, l) => s + (parseInt(l.duration) || 0), 0); + return ( +
+
onToggle(mod.id)} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 18px', cursor: 'pointer', userSelect: 'none' }}> + + +
+
{mod.title}
+
+ {lessonCount} درس · {totalMin > 0 ? `${totalMin} دقیقه` : 'بدون زمان‌بندی'} +
+
+ +
+ {mod.expanded && ( +
+ {mod.lessons.map(l => ( + + ))} + +
+ )} +
+ ); +} + +function LessonInspector({ lesson }) { + if (!lesson) return null; + const m = TYPE_META[lesson.type]; + return ( +
+ {/* preview frame */} +
+
+
+ +
+
+ + {lesson.duration} + + + {m.fa} + +
+
+ +
+
+
+ +
+
+ + +
+
+ +