diff --git a/.cursor/rules/encoach-project-context.mdc b/.cursor/rules/encoach-project-context.mdc new file mode 100644 index 00000000..b7ebc823 --- /dev/null +++ b/.cursor/rules/encoach-project-context.mdc @@ -0,0 +1,30 @@ +--- +description: EnCoach project context — auto-load summary and update it at end of session +alwaysApply: true +--- + +# EnCoach Project Context + +At the **start of every chat**, read `docs/PROJECT_SUMMARY.md` to understand the project state, credentials, architecture, API routes, and how to run the project. This is your primary reference for the EnCoach platform. + +At the **end of every chat** (when the user says goodbye, asks to wrap up, or the session's main task is complete), update `docs/PROJECT_SUMMARY.md` with any new information discovered or changes made during the session. This includes: + +- New or changed user credentials +- New API routes or changed endpoints +- New models or schema changes +- New modules installed or removed +- Git state changes (new branches, remotes, pushes) +- Bug fixes or known issues discovered +- New pages or components added to frontend +- Any configuration changes (odoo.conf, .env, etc.) + +## Key Facts + +- **Backend**: Odoo 19 at `http://127.0.0.1:8069` +- **Frontend**: Vite React at `http://localhost:8080` +- **Database**: `encoach_v2` on PostgreSQL 18 +- **Login route**: `POST /api/login` (not `/api/auth/login`) +- **PostgreSQL binary**: `/Users/yamenahmad/micromamba/envs/odoo19/bin/pg_ctl` (PG 18 — must use this, not conda PG 17) +- **Node.js**: `/Users/yamenahmad/.local/node/bin/` (must add to PATH) +- **odoo.conf must have**: `db_name = encoach_v2` and `dbfilter = ^encoach_v2$` +- **Git branch**: `v4` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c538fccb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # -------------------------------------------------------------------- + # Frontend: type-check, lint, build, and run Playwright smoke tests. + # -------------------------------------------------------------------- + frontend: + name: Frontend — lint + build + e2e + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit -p tsconfig.app.json + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright smoke tests + run: npm run test:e2e + env: + CI: "true" + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-report + retention-days: 7 + + # -------------------------------------------------------------------- + # Backend: run the Odoo HttpCase smoke suite via the official image. + # + # The image mounts our custom_addons tree and runs the test-tagged + # subset so we don't pay for a full install on every PR. + # -------------------------------------------------------------------- + backend: + name: Backend — Odoo HttpCase + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U odoo" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - name: Run Odoo HttpCase tests in a throwaway container + run: | + docker run --rm \ + --network host \ + -v "$GITHUB_WORKSPACE/backend/custom_addons:/mnt/extra-addons" \ + -e HOST=localhost \ + -e USER=odoo \ + -e PASSWORD=odoo \ + odoo:19 \ + --stop-after-init \ + --test-enable \ + --test-tags "encoach_api" \ + --log-level=warn \ + --without-demo=all \ + --init=encoach_api,encoach_core \ + -d encoach_ci || { + echo "::warning::Odoo HttpCase suite failed — see logs above." + exit 1 + } + env: + PGHOST: localhost + PGUSER: odoo + PGPASSWORD: odoo diff --git a/.gitignore b/.gitignore index 7caa8f83..321926c2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,12 @@ miniconda3/ # PostgreSQL data pgdata/ +pgdata_bak_*/ + +# Frontend build cache +frontend/.vite/ +frontend/dist/ +frontend/node_modules/ # IDE .vscode/ diff --git a/Dockerfile b/Dockerfile index 807107a0..11b8e420 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,11 @@ FROM odoo:19.0 USER root # Install all Python dependencies required by EnCoach custom addons. -# --ignore-installed: skip uninstalling system packages that lack RECORD files (e.g. Debian's typing_extensions). -COPY new_project/requirements.txt /tmp/requirements.txt +# --ignore-installed: skip uninstalling system packages that lack RECORD files +# (e.g. Debian's typing_extensions). +# Canonical source: repo-root requirements.txt (the legacy copy under +# new_project/ is no longer authoritative — see new_project/DEPRECATED.md). +COPY requirements.txt /tmp/requirements.txt RUN pip install -r /tmp/requirements.txt --break-system-packages --no-warn-script-location --ignore-installed USER odoo diff --git a/README.md b/README.md index f2bd2109..54cd4167 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,190 @@ -# EnCoach Backend v2 — Odoo 19 Adaptive Learning Platform +# EnCoach — Adaptive AI Learning Platform -The Odoo 19 backend for the **EnCoach** adaptive learning and institutional LMS platform. Provides ~377 REST API endpoints serving a React 18 SPA frontend. +EnCoach is a smart learning environment for individual and collaborative learning, +fully integrated with AI and equipped with intelligent, professional-grade +exercises, assessments, and e-exams. -## System Overview +This repository hosts the full stack: -| Component | Detail | -|-----------|--------| -| **Framework** | Odoo 19 Community Edition | -| **Language** | Python 3.12+ | -| **Database** | PostgreSQL 16 | -| **LMS Foundation** | OpenEduCat Community (14 modules, ported to v19) | -| **Custom Modules** | 27 `encoach_*` modules | -| **AI/ML** | OpenAI GPT-4o, Whisper (local), AWS Polly, ELAI, GPTZero, FAISS | -| **Authentication** | JWT (PyJWT) | -| **Deployment** | Docker Compose | +- **Backend** — Odoo 19 + ~30 `encoach_*` modules (Python 3.12, PostgreSQL 16) +- **Frontend** — React 18 + Vite + TypeScript single-page app +- **AI layer** — OpenAI + pgvector RAG, quality-gate validation, IELTS validator +- **Ops** — Docker Compose, JWT auth with refresh tokens, Prometheus-compatible + metrics, dynamic OpenAPI 3.0 spec -## Module Architecture +> **Canonical trees:** `backend/` (all server code) and `frontend/` (all client +> code). The legacy `new_project/` directory is deprecated — see +> [`new_project/DEPRECATED.md`](new_project/DEPRECATED.md). -``` -new_project/ - custom_addons/ # 27 custom EnCoach modules - encoach_api/ # REST API controllers (~120 routes) - encoach_lms_api/ # OpenEduCat bridge API (~209 routes) - encoach_adaptive_api/ # Adaptive learning API (~41 routes) - encoach_resources/ # Resource library API (~7 routes) - encoach_core/ # Users, entities, roles, permissions - encoach_taxonomy/ # Subject > Domain > Topic taxonomy - encoach_adaptive/ # Proficiency, learning plans, mastery - encoach_adaptive_ai/ # AI for diagnostics, coaching, content - encoach_exam/ # AI-powered exam engine - encoach_ai/ # OpenAI service wrapper - encoach_ai_generation/ # AI exam content generation - encoach_ai_grading/ # AI grading (writing, speaking, math, IT) - encoach_ai_media/ # TTS (Polly), STT (Whisper), ELAI avatars - encoach_courseware/ # Chapters, materials, AI workbench - encoach_communication/ # Discussion boards, announcements, messaging - encoach_notification/ # Notification engine, rules, preferences - encoach_faq/ # FAQ categories and items - encoach_approval/ # Approval workflows, stages - encoach_assignment/ # Exam-wrapper assignments - encoach_classroom/ # Virtual classroom groups - encoach_stats/ # Session tracking, statistics - encoach_training/ # Training tips, walkthroughs, FAISS - encoach_subscription/ # Packages, Stripe/PayPal/Paymob - encoach_registration/ # User registration, batch import - encoach_ticket/ # Support tickets - encoach_sis/ # UTAS SIS integration - encoach_branding/ # Whitelabeling - openeducat_erp-19.0/ # 14 OpenEduCat community modules - openeducat_core/ # Courses, batches, students, faculty - openeducat_timetable/ # Timetable sessions - openeducat_attendance/ # Attendance management - openeducat_exam/ # Institutional exams, marksheets - openeducat_assignment/ # Course assignments, submissions - openeducat_admission/ # Admission registers, applications - openeducat_classroom/ # Physical classrooms - openeducat_facility/ # Facilities and assets - openeducat_fees/ # Fee plans, student fees - openeducat_library/ # Library media, movements, cards - openeducat_activity/ # Student activities - openeducat_parent/ # Parent-student relationships - openeducat_erp/ # ERP connector - theme_web_openeducat/ # Web theme -``` +--- -## Staging +## 1. Quickstart -| Service | URL | -|---------|-----| -| **Odoo Backend** | `http://5.189.151.117:8069` | -| **React Frontend** | `http://5.189.151.117:3000` | - -## Branching Workflow - -This repo is connected to the staging server via a Git post-receive hook. -**All deployment is automatic after code review approval.** - -1. Never push directly to `main` -- branch protection will block it. -2. Push your changes to a feature branch (e.g. `feature/full-backend-v1`). -3. Open a Pull Request on Gitea targeting `main`. -4. Request review from **devops (Talal)**. -5. Once approved and merged, the staging server rebuilds and redeploys automatically. - -The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`. - -## Local Development - -### Option A: Docker (easiest) +### Docker (recommended) ```bash docker compose up -d +# Odoo: http://localhost:8069 +# Frontend: cd frontend && npm install && npm run dev (http://localhost:8080) ``` -Open `http://localhost:8069`. First run: create a new database. +Create a new Odoo database on first visit, then install the `encoach_api` +module to pull in every `encoach_*` dependency. -Stop: `docker compose down` +### Source -### Option B: Source (venv + PostgreSQL) - -**Prerequisites:** macOS, Homebrew, Python 3.11+, PostgreSQL 15+ +Prerequisites: Python 3.12, PostgreSQL 16, Node 20, npm 10. ```bash -brew install postgresql@15 wkhtmltopdf -brew services start postgresql@15 -createuser -s $(whoami) +# Backend +./setup.sh # creates venv, installs requirements.txt +./run.sh # starts Odoo on :8069 -chmod +x setup.sh -./setup.sh -./run.sh +# Frontend +cd frontend +npm install +npm run dev # starts Vite on :8080, proxies /api → :8069 ``` -Open `http://localhost:8069`. +See [`MANUAL-RUN.md`](MANUAL-RUN.md) for a step-by-step walkthrough and +[`CONNECT-POSTGRES.md`](CONNECT-POSTGRES.md) for database wiring. -See [MANUAL-RUN.md](MANUAL-RUN.md) for step-by-step commands. +--- -## Related Documents +## 2. Repository layout + +``` +odoo19/ +├── backend/ +│ └── custom_addons/ +│ ├── encoach_api/ REST base + JWT + OpenAPI + metrics +│ ├── encoach_core/ Users, entities, roles, permissions +│ ├── encoach_taxonomy/ Subject → Domain → Topic +│ ├── encoach_ai/ OpenAI wrapper, cefr_mapper, validator +│ ├── encoach_ai_course/ AI course/exam generation pipelines +│ ├── encoach_ai_grading/ AI grading (writing/speaking/math/IT) +│ ├── encoach_ai_media/ TTS (Polly), STT (Whisper), ELAI +│ ├── encoach_vector/ pgvector store + RAG embeddings +│ ├── encoach_exam_template/ Canonical exam + student attempt models +│ ├── encoach_scoring/ Score computation, CEFR mapping +│ ├── encoach_quality_gate/ Automated content-quality checks +│ ├── encoach_ielts_validation/ IELTS-specific validators +│ ├── encoach_adaptive/ Adaptive engine, style matcher +│ ├── encoach_lms_api/ OpenEduCat bridge + LMS endpoints +│ ├── encoach_branding/ White-label config per entity +│ └── … (other encoach_* modules) +├── frontend/ +│ ├── src/ +│ │ ├── pages/ Route pages (React.lazy code-split) +│ │ ├── components/ Shared UI (shadcn/ui + custom) +│ │ ├── services/ Thin API wrappers over fetch +│ │ ├── hooks/queries/ React Query hooks + keys +│ │ ├── lib/api-client.ts Fetch + auto JWT refresh +│ │ └── types/ Shared DTO types +│ └── vite.config.ts manualChunks: react, query, charts, radix… +├── docs/ +│ ├── PROJECT_SUMMARY.md Release notes & architecture history +│ ├── adr/ Architecture Decision Records +│ └── ENCOACH_*.md SRS, workflows, user stories +├── docker-compose.yml +├── Dockerfile +├── requirements.txt Python deps (pgvector, textstat, etc.) +└── README.md You are here +``` + +--- + +## 3. Architecture at a glance + +``` +┌────────────────────┐ HTTPS/JWT ┌────────────────────┐ +│ React SPA (Vite) │ ───────────────► │ Odoo 19 + FastAPI-style │ +│ - React Query │ ◄─────────────── │ controllers (encoach_*) │ +│ - next-themes │ X-Request-Id └──────┬─────────────┘ +│ - shadcn/ui │ │ +└────────────────────┘ ▼ + ┌──────────────┐ + │ PostgreSQL │ + │ + pgvector │ + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ OpenAI, │ + │ Whisper, │ + │ Polly… │ + └──────────────┘ +``` + +Every request carries an `X-Request-Id` and emits structured JSON logs. +Prometheus-compatible counters are exposed at `/api/metrics`, and the live +OpenAPI 3.0 spec is at `/api/openapi.json`. + +--- + +## 4. Key conventions + +- **Canonical response envelope** — list endpoints return + `{ items: T[], total, page, size, data: T[] }` (see `encoach_api.controllers.base.paginated_envelope`). +- **CEFR mapping** — only `encoach_ai.services.cefr_mapper` is canonical. Do + not reintroduce local `band_to_cefr` copies. +- **JWT tokens** — short-lived access tokens (1h) + revocable refresh tokens + (7d). Only `access` tokens are accepted as Bearer credentials; refresh tokens + must go through `/api/auth/refresh`. See [`docs/adr/0002-jwt-refresh-token-flow.md`](docs/adr/0002-jwt-refresh-token-flow.md). +- **RAG metadata** — vector embeddings carry `course_id`, `subject_id`, + `entity_id`, `taxonomy`, `content_hash`. Chunking kicks in above 2 000 chars. +- **Frontend pagination** — `PaginatedResponse` exposes both `items` and + `data`. Read from `items` in new code. +- **Frontend theming** — tokens live in `frontend/src/index.css` (`:root` and + `.dark`). Always use `hsl(var(--token))` instead of raw hex. + +--- + +## 5. Health, observability, docs + +| Endpoint | Purpose | +|---|---| +| `GET /api/health` | Liveness (always 200 when server is up) | +| `GET /api/health/ready` | Readiness (DB + required config) | +| `GET /api/openapi.json` | Dynamic OpenAPI 3.0 spec generated from `@http.route` | +| `GET /api/metrics` | Prometheus-format counters per route | + +--- + +## 6. Deployment + +Staging and production both use Docker Compose. The staging server rebuilds +automatically from `main`; never force-push. See +[`INSTALL-ODOO-SUMMARY.md`](INSTALL-ODOO-SUMMARY.md) for bootstrap notes. + +| Service | Staging URL | +|---|---| +| Odoo backend | `http://5.189.151.117:8069` | +| React frontend | `http://5.189.151.117:3000` | + +The `.env` file is **never** committed. On staging it lives at +`/opt/encoach/backend-v2/.env`. + +--- + +## 7. Further reading | Document | Description | -|----------|-------------| -| `ENCOACH_ODOO19_BACKEND_SRS.md` | Backend SRS v3.0 (definitive) | -| `ENCOACH_UNIFIED_SRS.md` | Unified frontend + backend SRS v2.0 | -| `ENCOACH_PRODUCT_DESCRIPTION.md` | Non-technical product description | +|---|---| +| [`docs/PROJECT_SUMMARY.md`](docs/PROJECT_SUMMARY.md) | Release notes + architecture history | +| [`docs/adr/`](docs/adr/) | Architecture Decision Records (why we built it this way) | +| [`docs/ENCOACH_UNIFIED_SRS.md`](docs/ENCOACH_UNIFIED_SRS.md) | Unified frontend + backend SRS | +| [`docs/ENCOACH_ODOO19_BACKEND_SRS.md`](docs/ENCOACH_ODOO19_BACKEND_SRS.md) | Backend SRS v3.0 | +| [`docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md`](docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md) | Backend workflows | +| [`docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md`](docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md) | Frontend workflows | + +--- + +## 8. Contributing + +1. Branch from `main` — never push direct. Branch protection enforces it. +2. Run `npx tsc --noEmit -p tsconfig.app.json` (frontend) and the module smoke + tests before opening a PR. +3. Every architectural decision should be captured as an ADR under + [`docs/adr/`](docs/adr/). Copy `0000-template.md` to start one. +4. Open the PR against `main` and request review from devops (Talal). diff --git a/backend/custom_addons/encoach_ai/__manifest__.py b/backend/custom_addons/encoach_ai/__manifest__.py index 1535e00d..c4929652 100644 --- a/backend/custom_addons/encoach_ai/__manifest__.py +++ b/backend/custom_addons/encoach_ai/__manifest__.py @@ -15,7 +15,7 @@ - AI Search, Insights, Report Narrative """, "author": "EnCoach", - "depends": ["base", "encoach_core"], + "depends": ["base", "encoach_core", "encoach_api"], "external_dependencies": { "python": ["openai", "boto3"], }, diff --git a/backend/custom_addons/encoach_ai/controllers/__init__.py b/backend/custom_addons/encoach_ai/controllers/__init__.py index fd28fabd..4520844b 100644 --- a/backend/custom_addons/encoach_ai/controllers/__init__.py +++ b/backend/custom_addons/encoach_ai/controllers/__init__.py @@ -1,3 +1,5 @@ from . import ai_controller from . import coach_controller from . import media_controller +from . import prompt_controller +from . import feedback_controller diff --git a/backend/custom_addons/encoach_ai/controllers/ai_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_controller.py index 32cf790a..18655c9f 100644 --- a/backend/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/ai_controller.py @@ -2,8 +2,9 @@ import json import logging -from odoo import http +from odoo import fields, http from odoo.http import request, Response +from odoo.addons.encoach_api.controllers.base import jwt_required _logger = logging.getLogger(__name__) @@ -27,7 +28,8 @@ class AIController(http.Controller): """Handles /api/ai/* endpoints consumed by frontend AI components.""" # ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ── - @http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/search", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_search(self, **kw): body = _get_json() query = body.get("query", "") @@ -43,7 +45,8 @@ class AIController(http.Controller): return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []}) # ── GET /api/ai/vector-search — pure semantic search without GPT ── - @http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/ai/vector-search", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def ai_vector_search(self, **kw): query = request.params.get("q", "") content_type = request.params.get("content_type") @@ -60,7 +63,8 @@ class AIController(http.Controller): return _json_response({"results": [], "query": query, "error": str(e)}) # ── POST /api/ai/insights — AiInsightsPanel.tsx ── - @http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/insights", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_insights(self, **kw): body = _get_json() try: @@ -76,7 +80,8 @@ class AIController(http.Controller): return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]}) # ── GET /api/ai/alerts — AiAlertBanner.tsx ── - @http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/ai/alerts", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def ai_alerts(self, **kw): try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService @@ -92,7 +97,8 @@ class AIController(http.Controller): return _json_response({"alerts": []}) # ── POST /api/ai/report-narrative — AiReportNarrative.tsx ── - @http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/report-narrative", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_report_narrative(self, **kw): body = _get_json() try: @@ -107,7 +113,8 @@ class AIController(http.Controller): return _json_response({"narrative": f"Report generation unavailable: {e}"}) # ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ── - @http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/batch-optimize", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_batch_optimize(self, **kw): body = _get_json() try: @@ -122,7 +129,8 @@ class AIController(http.Controller): return _json_response({"optimized": [], "summary": str(e), "impact": "none"}) # ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ── - @http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/grade-suggest", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_grade_suggest(self, **kw): body = _get_json() try: @@ -146,7 +154,8 @@ class AIController(http.Controller): return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []}) # ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ── - @http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/generate-resource", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_generate_resource(self, **kw): body = _get_json() try: @@ -162,7 +171,8 @@ class AIController(http.Controller): return _json_response({"resource": None, "status": "error", "error": str(e)}) # ── POST /api/ai/detect — GPTZero AI detection ── - @http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/detect", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_detect(self, **kw): body = _get_json() try: @@ -174,7 +184,8 @@ class AIController(http.Controller): return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)}) # ── POST /api/plagiarism/check — plagiarism.service.ts ── - @http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/plagiarism/check", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def plagiarism_check(self, **kw): body = _get_json() try: @@ -187,7 +198,8 @@ class AIController(http.Controller): return _json_response({"report_id": None, "error": str(e)}) # ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ── - @http.route("/api/domains//ai-suggest", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/domains//ai-suggest", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_suggest_topics(self, domain_id, **kw): body = _get_json() try: @@ -206,7 +218,8 @@ class AIController(http.Controller): return _json_response({"topics": [], "error": str(e)}) # ── POST /api/learning-plan/generate — LearningPlan.tsx ── - @http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/learning-plan/generate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def learning_plan_generate(self, **kw): body = _get_json() try: @@ -227,7 +240,8 @@ class AIController(http.Controller): return _json_response({"plan": None, "error": str(e)}) # ── Workbench endpoints — AiWorkbench.tsx ── - @http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-outline", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_outline(self, **kw): body = _get_json() try: @@ -244,7 +258,8 @@ class AIController(http.Controller): except Exception as e: return _json_response({"chapters": [], "error": str(e)}) - @http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-chapter", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_chapter(self, **kw): body = _get_json() try: @@ -262,7 +277,8 @@ class AIController(http.Controller): except Exception as e: return _json_response({"content": "", "error": str(e)}) - @http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-rubric", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_rubric(self, **kw): body = _get_json() try: @@ -280,11 +296,13 @@ class AIController(http.Controller): except Exception as e: return _json_response({"rubric": None, "error": str(e)}) - @http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/regenerate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_regenerate(self, **kw): return self.workbench_chapter(**kw) - @http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/publish", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_publish(self, **kw): body = _get_json() try: @@ -314,96 +332,542 @@ class AIController(http.Controller): _logger.exception("workbench publish failed") return _json_response({"status": "error", "error": str(e)}, 500) + # ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ── + @http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required + def ai_suggest_rubric_criteria(self, **kw): + from odoo.addons.encoach_api.controllers.base import validate_token + user = validate_token() + if not user: + return _json_response({"error": "Authentication required"}, 401) + request.update_env(user=user.id) + + body = _get_json() + name = body.get("name", "") + skill = body.get("skill", "writing") + exam_type = body.get("exam_type", "academic") + levels = body.get("levels", ["A1", "A2", "B1", "B2", "C1", "C2"]) + + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + if not ai.client: + raise RuntimeError("OpenAI not configured") + + band_keys = ", ".join(f'"{lv}"' for lv in levels) + messages = [ + {"role": "system", "content": ( + "You are an expert in English language assessment rubric design. " + "Generate scoring criteria for a rubric. Return 3-6 criteria.\n\n" + "Each criterion must have:\n" + "- name: short name (e.g. 'Task Achievement')\n" + "- weight: percentage weight (all weights must sum to 100)\n" + "- descriptors: an object mapping ONLY these band levels to a 1-sentence description of expected performance at that level\n\n" + f"The ONLY allowed band level keys are: {band_keys}\n\n" + "Return ONLY this JSON structure:\n" + '{"criteria": [{"name": "string", "weight": number, ' + '"descriptors": {"LEVEL": "one sentence description", ...}}]}' + )}, + {"role": "user", "content": json.dumps({ + "rubric_name": name, + "skill": skill, + "exam_type": exam_type, + "target_levels": levels, + })}, + ] + result = ai.chat_json(messages, action="suggest_rubric_criteria") + return _json_response(result) + except Exception as e: + _logger.warning("AI unavailable (%s), using template criteria for %s/%s", e, skill, exam_type) + return _json_response({"criteria": self._fallback_criteria(skill, levels)}) + + @staticmethod + def _fallback_criteria(skill, levels): + """Return pre-built criteria templates when OpenAI is unavailable.""" + def _desc(level_map, levels): + return {lv: level_map.get(lv, "") for lv in levels} + + templates = { + "writing": [ + {"name": "Task Achievement", "weight": 25, "descriptors_map": { + "C2": "Fully addresses all parts with a well-developed position", + "C1": "Addresses all parts with a clear position throughout", + "B2": "Addresses all parts, though some more fully than others", + "B1": "Addresses the task only partially with limited development", + "A2": "Barely responds to the task with very limited ideas", + "A1": "Does not adequately address the task requirements", + }}, + {"name": "Coherence & Cohesion", "weight": 25, "descriptors_map": { + "C2": "Skillfully manages paragraphing with seamless cohesion", + "C1": "Logically organizes information with clear progression", + "B2": "Arranges information coherently with some cohesive devices", + "B1": "Presents information with some organization but may lack clarity", + "A2": "Limited ability to organize ideas; unclear progression", + "A1": "No apparent logical organization of ideas", + }}, + {"name": "Lexical Resource", "weight": 25, "descriptors_map": { + "C2": "Uses a wide range of vocabulary with very natural and sophisticated control", + "C1": "Uses a sufficient range of vocabulary to allow flexibility and precision", + "B2": "Uses an adequate range of vocabulary for the task with some errors", + "B1": "Uses a limited range of vocabulary with noticeable errors", + "A2": "Uses only basic vocabulary with frequent errors in word choice", + "A1": "Extremely limited vocabulary; barely able to convey meaning", + }}, + {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": { + "C2": "Wide range of structures with full flexibility and accuracy", + "C1": "Uses a variety of complex structures with good control", + "B2": "Uses a mix of simple and complex sentences with some errors", + "B1": "Attempts complex sentences but errors are frequent", + "A2": "Uses only simple sentences with many errors", + "A1": "Cannot use sentence forms except in memorized phrases", + }}, + ], + "speaking": [ + {"name": "Fluency & Coherence", "weight": 25, "descriptors_map": { + "C2": "Speaks effortlessly with natural flow and fully coherent speech", + "C1": "Speaks at length without noticeable effort or loss of coherence", + "B2": "Speaks with some hesitation but maintains coherent speech", + "B1": "Can keep going but pauses frequently to plan and correct", + "A2": "Produces simple utterances with long pauses", + "A1": "Speech is extremely slow with very long pauses", + }}, + {"name": "Lexical Resource", "weight": 25, "descriptors_map": { + "C2": "Uses vocabulary with full flexibility and precision in all topics", + "C1": "Uses vocabulary flexibly to discuss a variety of topics", + "B2": "Has a wide enough vocabulary to discuss topics at length", + "B1": "Uses sufficient vocabulary for familiar topics", + "A2": "Uses basic vocabulary for personal information and routine situations", + "A1": "Can only produce isolated words and memorized phrases", + }}, + {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": { + "C2": "Maintains consistent use of a wide range of accurate structures", + "C1": "Uses a wide range of structures with a majority of error-free sentences", + "B2": "Uses a range of structures with reasonable accuracy", + "B1": "Produces basic sentence forms with reasonable accuracy", + "A2": "Produces basic sentences with frequent errors", + "A1": "Cannot produce basic sentence forms", + }}, + {"name": "Pronunciation", "weight": 25, "descriptors_map": { + "C2": "Is effortless to understand with natural pronunciation features", + "C1": "Uses a wide range of pronunciation features with fine control", + "B2": "Is generally easy to understand with some L1 influence", + "B1": "Shows some effective use of features but may be inconsistent", + "A2": "Pronunciation is generally understood but often faulty", + "A1": "Speech is often unintelligible due to pronunciation errors", + }}, + ], + } + + base = templates.get(skill, templates["writing"]) + return [ + { + "name": c["name"], + "weight": c["weight"], + "descriptors": _desc(c["descriptors_map"], levels), + } + for c in base + ] + # ── Exam generation — GenerationPage.tsx ── - @http.route("/api/exam//generate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam//generate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def exam_generate(self, module, **kw): + from odoo.addons.encoach_api.controllers.base import validate_token + user = validate_token() + if not user: + return _json_response({"error": "Authentication required"}, 401) + request.update_env(user=user.id) body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) + has_ai = bool(ai.client) + except Exception: + ai, has_ai = None, False + try: if body.get("generate_passage"): - return self._generate_passage(ai, body) + if has_ai: + return self._generate_passage(ai, body) + return _json_response(self._fallback_passage(body)) if body.get("generate_instructions"): - return self._generate_writing_instructions(ai, body) + if has_ai: + return self._generate_writing_instructions(ai, body) + return _json_response(self._fallback_writing_instructions(body)) if body.get("generate_script"): - return self._generate_speaking_script(ai, body) + if has_ai: + return self._generate_speaking_script(ai, body) + return _json_response(self._fallback_speaking_script(body)) if body.get("generate_context"): - return self._generate_listening_context(ai, body) + if has_ai: + return self._generate_listening_context(ai, body) + return _json_response(self._fallback_listening_context(body)) if body.get("generate_exercises"): - return self._generate_exercises(ai, module, body) + if has_ai: + return self._generate_exercises(ai, module, body) + return _json_response(self._fallback_exercises(module, body)) difficulty = body.get("difficulty", "B2") topic = body.get("topic", "") count = body.get("count") or body.get("question_count") or 5 - messages = [ - {"role": "system", "content": ( - f"Generate {count} exam questions for the {module} module at {difficulty} level. " - f"Return JSON: " - '{"questions": [{"type": string, "prompt": string, "options": [string], ' - '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}' - )}, - {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})}, - ] - return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) + if has_ai: + messages = [ + {"role": "system", "content": ( + f"Generate {count} exam questions for the {module} module at {difficulty} level. " + f"Return JSON: " + '{"questions": [{"type": string, "prompt": string, "options": [string], ' + '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}' + )}, + {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})}, + ] + return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) + return _json_response(self._fallback_questions(module, body)) except Exception as e: _logger.exception("exam_generate %s failed: %s", module, e) return _json_response({"questions": [], "error": str(e)}, 500) + def _get_material_context(self, ai, query, course_id=None, subject_id=None, entity_id=None): + """Fetch relevant course material / resource context from the vector store for + RAG-enhanced generation. + + Results are re-ranked to prefer (in order): + same course_id → same subject_id → same entity_id → everything else. + """ + try: + context_results = ai._get_vector_context( + query, + content_types=['material', 'resource', 'course', 'topic', 'learning_objective'], + limit=8, + ) + if not context_results: + return "" + + def _score(r): + meta = r.get('metadata', {}) or {} + s = 0 + if course_id and meta.get('course_id') == course_id: + s += 100 + if subject_id and meta.get('subject_id') == subject_id: + s += 50 + if entity_id and meta.get('entity_id') == entity_id: + s += 25 + s += float(r.get('similarity', 0) or 0) * 10 + return -s + context_results.sort(key=_score) + return ai._format_context(context_results[:6]) + except Exception: + return "" + + # ── Persona / CEFR helpers for exam generation ──────────────────── + # Concise CEFR descriptors used to give GPT a mental model of each band. + # Keep these short — they are prepended to every prompt. + _CEFR_DESCRIPTORS = { + "A1": "A1 (Breakthrough) — very basic personal information; concrete vocabulary; short fixed phrases; simple present tense; text ~100-200 words with predictable structure.", + "A2": "A2 (Elementary) — familiar everyday topics; simple past / future; concrete connectors (and, but, because); text ~200-300 words.", + "B1": "B1 (Threshold) — familiar matters at work/school/leisure; clear standard language; can follow main points; text ~300-400 words; some abstract ideas.", + "B2": "B2 (Vantage) — complex texts on concrete and abstract topics; including technical discussions in speciality; text ~400-600 words; clear detailed argument.", + "C1": "C1 (Effective Operational Proficiency) — wide range of demanding long texts; implicit meaning; flexible and effective language use; nuanced argument; text ~600-900 words; idiomatic expressions; complex sub-clauses.", + "C2": "C2 (Mastery) — understand virtually everything with ease; reconstruct arguments; precise shades of meaning; text ~800-1200 words; sophisticated stylistic choices.", + } + + # Examiner / item-writer personas keyed by module + exam_mode. + _EXAM_MODE_LABEL = { + "official": "official high-stakes IELTS exam (summative, ranked Band 0-9)", + "practice": "practice / formative exam (low-stakes, used for learner feedback)", + } + + def _persona_for(self, module, exam_mode="official", exam_type="academic"): + mode = self._EXAM_MODE_LABEL.get(exam_mode, "standardised English exam") + et = (exam_type or "academic").lower() + et_pretty = { + "academic": "IELTS Academic", + "general": "IELTS General Training", + "general_training": "IELTS General Training", + "business": "Business English", + "ket": "Cambridge KET", "pet": "Cambridge PET", + "fce": "Cambridge FCE", "cae": "Cambridge CAE", "cpe": "Cambridge CPE", + }.get(et, et.title()) + base = f"You are a senior {et_pretty} item writer preparing content for a {mode}." + extras = { + "reading": " You specialise in CEFR-aligned reading passages whose lexical range, grammatical complexity, cohesion devices, and topic sophistication match the target band exactly.", + "listening": " You specialise in CEFR-aligned listening scripts with realistic features (hesitations, discourse markers, accents) while staying lexically and grammatically appropriate to the target band.", + "writing": " You specialise in CEFR-aligned writing prompts that elicit the task response, lexical range, grammatical range and coherence expected at the target band.", + "speaking": " You specialise in CEFR-aligned speaking cue cards and examiner scripts whose follow-up questions elicit the fluency, lexical resource, grammatical range and pronunciation expected at the target band.", + } + return base + extras.get(module, "") + + def _build_persona_context(self, body): + """Format the user-captured parameters into a readable 'exam brief' block + that the LLM sees as a dedicated system message.""" + rows = [] + + def _add(label, value): + if value in (None, "", [], {}): + return + if isinstance(value, list): + value = ", ".join(str(v) for v in value if v not in (None, "")) + if not value: + return + rows.append(f"- {label}: {value}") + + _add("Exam title", body.get("exam_title") or body.get("title")) + _add("Exam label", body.get("exam_label") or body.get("label")) + _add("Exam mode", body.get("exam_mode")) + _add("Exam structure", body.get("structure_name") or body.get("structure")) + _add("Module", body.get("module")) + _add("CEFR target level", body.get("difficulty")) + _add("Passage category", body.get("category")) + _add("Passage type", body.get("passage_type") or body.get("type")) + _add("Task type", body.get("task_type")) + _add("Speaking part", body.get("part")) + _add("Listening section type", body.get("section_type")) + _add("Target word count", body.get("word_count")) + _add("Subject", body.get("subject_name") or body.get("subject")) + _add("Topic", body.get("topic") or body.get("topics")) + _add("Entity / Organisation", body.get("entity_name")) + _add("Rubric", body.get("rubric_name")) + _add("Grading system", body.get("grading_system")) + _add("Access type", body.get("access_type")) + + if not rows: + return "" + return "EXAM BRIEF — follow these parameters strictly:\n" + "\n".join(rows) + + def _build_rag_query(self, body, fallback=""): + """Combine topic / category / type / subject / objective into a single + semantic query for vector RAG (much more specific than just 'topic').""" + parts = [] + for key in ("topic", "topics", "category", "passage_type", "type", + "task_type", "section_type", "subject_name", "module"): + v = body.get(key) + if isinstance(v, list): + parts.extend(str(x) for x in v if x) + elif v: + parts.append(str(v)) + if not parts and fallback: + parts.append(fallback) + return " ".join(parts)[:400] + def _generate_passage(self, ai, body): topic = body.get("topic", "general knowledge") difficulty = body.get("difficulty", "B2") - word_count = body.get("word_count", 300) - messages = [ - {"role": "system", "content": ( - f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. " - "The passage should be suitable for an English language exam. " - 'Return JSON: {"passage": "the full passage text", "title": "passage title"}' - )}, - {"role": "user", "content": f"Topic: {topic}"}, - ] + word_count = int(body.get("word_count") or 300) + passage_type = (body.get("passage_type") or body.get("type") or "academic").lower() + category = body.get("category", "") + exam_mode = body.get("exam_mode", "official") + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + persona = self._persona_for("reading", exam_mode, passage_type) + persona_ctx = self._build_persona_context({**body, "module": "reading"}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + system_parts = [persona] + if cefr: + system_parts.append("CEFR TARGET LEVEL\n" + cefr) + system_parts.append( + f"TASK\nWrite ONE {passage_type} reading passage of approximately {word_count} words " + f"(±10%). The passage must be authentic-feeling, coherent, and pitched exactly at " + f"CEFR {difficulty}. Sentence length, clause complexity, lexical range and cohesion " + f"markers must match the target band. Avoid regional slang. Do NOT mention CEFR, IELTS " + f"or grading inside the passage itself." + ) + if category: + system_parts.append(f"DOMAIN / CATEGORY\nThe passage must be grounded in the domain: '{category}'.") + system_parts.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"title": "short catchy title (<=10 words)",' + ' "passage": "the full passage text, use \\n\\n between paragraphs",' + ' "paragraph_count": int,' + ' "approx_word_count": int,' + ' "key_vocabulary": [string, ...],' + ' "rhetorical_structure": "e.g. problem-solution / compare-contrast / chronological",' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(system_parts)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — use these curated EnCoach resources to ground the " + "passage in what learners have been studying. Match terminology, examples and " + "scope; DO NOT copy verbatim:\n\n" + material_context + )}) + messages.append({"role": "user", "content": f"Topic: {topic}"}) return _json_response(ai.chat_json(messages, action="generate_passage")) def _generate_writing_instructions(self, ai, body): topic = body.get("topic", "general") - difficulty = body.get("difficulty", "A1") - task_type = body.get("task_type", "letter") - messages = [ - {"role": "system", "content": ( - f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. " - "Include clear instructions that tell the student what to write about. " - 'Return JSON: {"instructions": "the full instructions text"}' - )}, - {"role": "user", "content": f"Topic: {topic}"}, - ] + difficulty = body.get("difficulty", "B2") + task_type = body.get("task_type", "essay") + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + word_limit = int(body.get("word_limit") or (250 if task_type == "essay" else 150)) + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + rubric_name = body.get("rubric_name", "") + + persona = self._persona_for("writing", exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": "writing"}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + sys.append( + f"TASK\nWrite the STUDENT-FACING instructions for a '{task_type}' writing task at " + f"CEFR {difficulty}. The task must be completable in roughly {word_limit} words. " + "Use a neutral, authoritative exam register. Include any bullet-point sub-prompts a " + "student needs to address (e.g. 'explain', 'describe', 'suggest'). Do NOT write the " + "model answer — only the instructions." + ) + if rubric_name: + sys.append( + f"RUBRIC AWARENESS\nThe student answer will be graded with the rubric " + f"'{rubric_name}'. Frame the instructions so that the task naturally elicits the " + "criteria that rubric evaluates (task response, coherence, lexical resource, " + "grammatical range)." + ) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"instructions": "the full student-facing instructions (plain text, \\n for line breaks)",' + ' "suggested_word_limit": int,' + ' "evaluation_focus": [string, ...],' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — ground the task in topics students have studied:\n\n" + + material_context + )}) + messages.append({"role": "user", "content": f"Topic: {topic}"}) return _json_response(ai.chat_json(messages, action="generate_writing_instructions")) def _generate_speaking_script(self, ai, body): topics = body.get("topics", []) difficulty = body.get("difficulty", "B1") part = body.get("part", "speaking_1") - topic_str = ", ".join(t for t in topics if t) if topics else "general conversation" - messages = [ - {"role": "system", "content": ( - f"Generate a speaking exam script for {part} at CEFR {difficulty} level. " - "Include examiner questions and prompts for the student. " - 'Return JSON: {"script": "the full script text"}' - )}, - {"role": "user", "content": f"Topics: {topic_str}"}, - ] + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + topic_str = ", ".join(t for t in topics if t) if topics else (body.get("topic") or "general conversation") + + persona = self._persona_for("speaking", exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": "speaking", "topics": topics, "topic": topic_str}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic_str) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + part_guidance = { + "speaking_1": "Part 1 — Introduction & Interview: 4-6 short personal warm-up questions (15-30s answers).", + "speaking_2": "Part 2 — Individual Long Turn: ONE cue card with 3-4 bullet points; 1 min prep, 1-2 min monologue; 2-3 examiner follow-ups.", + "speaking_3": "Part 3 — Two-way Discussion: 4-6 abstract/analytical questions linked to Part 2 theme (45-60s answers).", + "interactive": "Interactive role-play: a realistic conversational scenario with turns labelled.", + }.get(part, "Mixed speaking prompts") + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + sys.append("TASK\n" + part_guidance + ( + f" Level-pitch the questions so a {difficulty} candidate is genuinely stretched " + "but not overwhelmed. Label every line as 'Examiner:' or 'Candidate:' (for examples) " + "or keep to 'Examiner:' only if you prefer. Use neutral British English register." + )) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"script": "the full examiner script (plain text, use \\n for line breaks)",' + ' "follow_up_questions": [string, ...],' + ' "cue_card_bullets": [string, ...] // only for speaking_2, else [],' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — anchor the prompts in themes the candidate has studied:\n\n" + + material_context + )}) + messages.append({"role": "user", "content": f"Topics: {topic_str}"}) return _json_response(ai.chat_json(messages, action="generate_speaking_script")) def _generate_listening_context(self, ai, body): topic = body.get("topic", "everyday life") section_type = body.get("section_type", "social_conversation") - messages = [ - {"role": "system", "content": ( - f"Generate a listening section transcript for a {section_type.replace('_', ' ')} " - "in an English language exam. Include speaker labels. " - 'Return JSON: {"context": "the full conversation/monologue transcript"}' - )}, - {"role": "user", "content": f"Topic: {topic}"}, - ] + difficulty = body.get("difficulty", "B1") + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + persona = self._persona_for("listening", exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": "listening"}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + section_guidance = { + "social_conversation": "two speakers in a day-to-day social context (2-4 min); include natural discourse markers, hesitations and back-channels.", + "social_monologue": "one speaker in a non-academic context, e.g. tour, announcement, instructions (2-3 min).", + "academic_conversation": "2-4 speakers in a university / study context (seminar, tutorial).", + "academic_lecture": "one lecturer delivering a ~4 min academic talk with clear signposting.", + }.get(section_type, f"a {section_type.replace('_', ' ')}") + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + sys.append( + "TASK\nWrite a listening transcript for " + section_guidance + + f" Pitch vocabulary, speed implications and idiomaticity to CEFR {difficulty}. " + "Label EVERY turn with a speaker tag (e.g. 'Presenter:', 'Student A:', 'Tutor:'). " + "Include realistic features like fillers ('er', 'you know'), self-corrections, " + "and conversational overlap where appropriate — but stay intelligible." + ) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"context": "the full labelled transcript",' + ' "speakers": [{"label": string, "description": string}],' + ' "approx_duration_seconds": int,' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — weave in concepts students have encountered:\n\n" + + material_context + )}) + messages.append({"role": "user", "content": f"Topic: {topic}"}) return _json_response(ai.chat_json(messages, action="generate_listening_context")) def _generate_exercises(self, ai, module, body): @@ -411,56 +875,463 @@ class AIController(http.Controller): exercise_types = body.get("exercise_types", []) type_counts = body.get("type_counts", {}) type_instructions = body.get("type_instructions", {}) + type_difficulties = body.get("type_difficulties", {}) or {} default_count = body.get("count_per_type", 5) difficulty = body.get("difficulty", "B2") + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + persona = self._persona_for(module if module in ("reading", "listening") else "reading", + exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": module}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=passage_text[:200]) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) type_specs = [] total = 0 + level_set = set() for et in exercise_types: c = int(type_counts.get(et, default_count)) instr = type_instructions.get(et, "") - spec_line = f"- EXACTLY {c} questions of type \"{et}\"" + lvl = type_difficulties.get(et) or difficulty + level_set.add(lvl) + spec_line = f"- EXACTLY {c} questions of type \"{et}\" pitched at CEFR {lvl}" if instr: spec_line += f"\n Student instructions: \"{instr}\"" type_specs.append(spec_line) total += c - spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions" + spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions at CEFR {difficulty}" - messages = [ - {"role": "system", "content": ( - f"You are an exam question generator. Generate EXACTLY {total} exercises " - f"at CEFR {difficulty} level based on the passage below.\n\n" - f"REQUIRED question breakdown (you MUST follow these counts exactly):\n" - f"{spec_str}\n\n" - "CRITICAL RULES:\n" - f"1. The total number of questions in your response MUST be exactly {total}.\n" - "2. Each question MUST have a 'type' field set to one of the requested types.\n" - "3. Each question MUST include an 'instructions' field with the student-facing instructions " - "for that section (use the provided instructions, or write appropriate ones).\n" - "4. For mcq/true_false types: include 'options' array and 'correct_answer'.\n" - "5. For fill_blanks/write_blanks types: use '___' in the prompt for blanks, " - "set correct_answer to the missing word(s), options can be empty.\n" - "6. For paragraph_match: prompt describes what to match, options are paragraph labels.\n\n" - "Return JSON:\n" - '{"questions": [{"type": string, "instructions": string, "prompt": string, ' - '"options": [string], "correct_answer": string, "explanation": string, "marks": number}]}' - )}, - {"role": "user", "content": passage_text[:3000]}, - ] + extra_cefr_block = "" + if len(level_set) > 1: + extra_cefr_block = "PER-TYPE CEFR LEVELS\n" + "\n".join( + f"- {lvl}: {self._CEFR_DESCRIPTORS.get(lvl, '')}" for lvl in sorted(level_set) + ) + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + if extra_cefr_block: + sys.append(extra_cefr_block) + # How many items should require inferential/higher-order thinking + higher_order_target = 0 + if difficulty in ("B2", "C1", "C2"): + higher_order_target = max(1, total // 3) + sys.append( + f"TASK\nWrite EXACTLY {total} exam questions based strictly on the source text below. " + "Every question MUST be answerable from that text alone — no outside knowledge. " + "Distractors for MCQs must be plausible, drawn from or consistent with the text, and of " + "roughly equal length and grammatical form. Do not repeat stems. Vary cognitive load: " + "include both literal-retrieval and inferential items." + + (f" At least {higher_order_target} of the {total} items MUST require inference, " + "implication or synthesis rather than literal lookup." if higher_order_target else "") + + f"\n\nREQUIRED BREAKDOWN (respect counts and per-type CEFR exactly):\n{spec_str}" + ) + sys.append( + "TYPE RULES\n" + "1. mcq: 4 options, exactly one correct, 'options' is an array of strings, " + "'correct_answer' is the exact string of the correct option.\n" + "2. true_false: options = ['TRUE','FALSE','NOT GIVEN']; answer is one of those.\n" + "3. fill_blanks / write_blanks: put '___' inside 'prompt' where the blank is, " + "'correct_answer' is the filler word(s) taken verbatim from the text, 'options' = [].\n" + "4. paragraph_match / matching_headings: 'prompt' is the statement/heading, " + "'options' is the list of paragraph labels, 'correct_answer' is the correct label.\n" + "5. short_answer / summary_completion: 'options' = [], 'correct_answer' is the expected answer.\n" + "6. Every question MUST include 'source_paragraph' (1-indexed number of the paragraph " + "the answer is drawn from) and a non-empty 'explanation' that quotes or paraphrases " + "the supporting span from that paragraph." + ) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"questions": [{"type": string, "instructions": string, "prompt": string, ' + '"options": [string], "correct_answer": string, "explanation": string, ' + '"source_paragraph": int, "cognitive_level": "literal"|"inferential"|"evaluative", ' + '"marks": number, "cefr_level": string}]}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — keep terminology and scope consistent with curriculum:\n\n" + + material_context + )}) + messages.append({ + "role": "user", + "content": "SOURCE TEXT (authoritative — every answer must come from here):\n\n" + + (passage_text[:3000] or "(no source text provided)") + }) return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}")) + # ── Fallback generators (no OpenAI needed) ── + + @staticmethod + def _fallback_passage(body): + topic = body.get("topic", "travel") + difficulty = body.get("difficulty", "B2") + templates = { + "travel": { + "title": "The Rise of Sustainable Tourism", + "passage": ( + "In recent years, sustainable tourism has emerged as a powerful movement reshaping how " + "people explore the world. Unlike traditional mass tourism, which often prioritises " + "convenience and cost over environmental impact, sustainable tourism encourages travellers " + "to consider their ecological footprint and cultural sensitivity.\n\n" + "The concept gained significant traction after international organisations highlighted the " + "devastating effects of unchecked tourism on fragile ecosystems. Coral reefs in Southeast " + "Asia, ancient ruins in South America, and wildlife reserves in Africa have all suffered " + "from overcrowding, pollution, and habitat destruction caused by the influx of visitors.\n\n" + "Governments and local communities have responded by implementing measures such as visitor " + "caps, eco-certification programmes, and community-based tourism initiatives. In Bhutan, " + "for example, the government charges a daily sustainable development fee to limit tourist " + "numbers while funding conservation efforts and education.\n\n" + "Tour operators have also adapted their business models. Many now offer carbon-offset " + "programmes, partner with local artisans and guides, and design itineraries that minimise " + "environmental disruption. Accommodation providers have invested in solar energy, rainwater " + "harvesting, and waste-reduction systems.\n\n" + "Despite these positive developments, challenges remain. Critics argue that sustainable " + "tourism can be exclusionary, pricing out budget travellers and local residents. Others " + "point out that certification schemes vary widely in rigour and transparency. Nevertheless, " + "the growing awareness among travellers suggests that the industry is moving in the right " + "direction, balancing economic growth with environmental stewardship." + ), + }, + "technology": { + "title": "Artificial Intelligence in Everyday Life", + "passage": ( + "Artificial intelligence, once confined to research laboratories and science fiction, has " + "become an integral part of daily life. From voice-activated assistants on smartphones to " + "recommendation algorithms on streaming platforms, AI systems now influence many of the " + "choices people make without them even realising it.\n\n" + "One of the most visible applications of AI is in healthcare. Machine learning algorithms " + "can now analyse medical images with remarkable accuracy, sometimes identifying conditions " + "such as early-stage cancers that human radiologists might miss. Hospitals around the world " + "are adopting AI-powered tools for patient triage, drug discovery, and personalised " + "treatment planning.\n\n" + "In education, AI-driven platforms adapt learning content to individual student needs. These " + "systems monitor a learner's progress and adjust the difficulty and style of materials " + "accordingly, providing a customised experience that traditional classroom settings often " + "cannot match.\n\n" + "However, the rapid adoption of AI has raised important ethical questions. Issues of data " + "privacy, algorithmic bias, and job displacement have sparked intense debate among " + "policymakers, technologists, and the general public. There are growing calls for " + "regulations that ensure AI systems are transparent, fair, and accountable.\n\n" + "As AI continues to evolve, its impact on society will only deepen. The challenge lies in " + "harnessing its potential for good while mitigating the risks it poses to privacy, " + "employment, and social equity." + ), + }, + } + t = topic.lower().strip() + if t in templates: + return templates[t] + default = templates["travel"] + default["title"] = f"{topic.title()} — A {difficulty} Level Reading Passage" + default["passage"] = default["passage"].replace("sustainable tourism", topic.lower()) + return default + + @staticmethod + def _fallback_writing_instructions(body): + topic = body.get("topic", "general") + difficulty = body.get("difficulty", "B2") + task_type = body.get("task_type", "essay") + templates = { + "essay": ( + f"Write an essay of at least 250 words on the following topic:\n\n" + f"\"{topic.title()}\"\n\n" + "You should:\n" + "• present a clear position on the issue\n" + "• support your arguments with relevant examples\n" + "• organise your ideas logically with clear paragraphing\n" + "• use a range of vocabulary and grammatical structures\n\n" + "Write at least 250 words." + ), + "report": ( + f"The chart/graph below shows information about {topic.lower()}.\n\n" + "Summarise the information by selecting and reporting the main features, " + "and make comparisons where relevant.\n\n" + "Write at least 150 words." + ), + "letter": ( + f"You recently had an experience related to {topic.lower()}. " + "Write a letter to a friend describing what happened.\n\n" + "In your letter:\n" + "• explain the situation\n" + "• describe how you felt\n" + "• suggest what your friend should do in a similar situation\n\n" + "Write at least 150 words. You do NOT need to write any addresses." + ), + } + return {"instructions": templates.get(task_type, templates["essay"])} + + @staticmethod + def _fallback_speaking_script(body): + part = body.get("part", "speaking_1") + topics = body.get("topics", []) + topic_str = ", ".join(t for t in topics if t) or "general conversation" + + scripts = { + "speaking_1": ( + f"Part 1 — Introduction and Interview\n\n" + f"Examiner: Good morning/afternoon. My name is [Examiner]. " + f"Can you tell me your full name, please?\n\n" + f"Now I'd like to ask you some questions about {topic_str}.\n\n" + f"1. Can you tell me about your experience with {topic_str}?\n" + f"2. How important is {topic_str} in your daily life?\n" + f"3. Has your interest in {topic_str} changed over the years?\n" + f"4. What do most people in your country think about {topic_str}?\n" + ), + "speaking_2": ( + f"Part 2 — Individual Long Turn\n\n" + f"Examiner: Now I'm going to give you a topic, and I'd like you to talk " + f"about it for one to two minutes. You have one minute to prepare.\n\n" + f"Describe a time when you experienced something related to {topic_str}.\n\n" + f"You should say:\n" + f"• what happened\n" + f"• when and where it happened\n" + f"• who was involved\n" + f"and explain how you felt about it.\n" + ), + "speaking_3": ( + f"Part 3 — Two-way Discussion\n\n" + f"Examiner: We've been talking about {topic_str}, and now I'd like to " + f"discuss some broader questions related to this topic.\n\n" + f"1. How has {topic_str} changed in your country in recent years?\n" + f"2. Do you think {topic_str} will be more or less important in the future? Why?\n" + f"3. What are the advantages and disadvantages of {topic_str}?\n" + f"4. How might governments address challenges related to {topic_str}?\n" + ), + } + return {"script": scripts.get(part, scripts["speaking_1"])} + + @staticmethod + def _fallback_listening_context(body): + topic = body.get("topic", "everyday life") + section_type = body.get("section_type", "social_conversation") + + transcripts = { + "social_conversation": ( + f"[A conversation between two friends about {topic}]\n\n" + "Speaker A: Hi! I haven't seen you in ages. How have you been?\n\n" + "Speaker B: I've been great, thanks. Actually, I've been quite busy lately " + f"because I've been working on something related to {topic}.\n\n" + "Speaker A: Oh really? That sounds interesting. Tell me more about it.\n\n" + f"Speaker B: Well, it started about three months ago when I decided to " + f"explore {topic} more seriously. I joined a local group and we meet every " + f"Tuesday evening to discuss different aspects of it.\n\n" + "Speaker A: That sounds fantastic. Have you learned a lot?\n\n" + "Speaker B: Absolutely. I've discovered that there's much more to it than " + "I originally thought. For instance, did you know that most experts " + "recommend starting with the basics before moving to advanced topics?\n\n" + "Speaker A: I didn't know that. Maybe I should join your group too.\n\n" + "Speaker B: You'd be very welcome! The next meeting is this Tuesday at " + "seven o'clock in the community centre on Park Road." + ), + "academic_lecture": ( + f"[An academic lecture about {topic}]\n\n" + f"Professor: Good morning, everyone. Today we'll be discussing {topic} " + "and its significance in the modern world.\n\n" + f"As you may already know, research into {topic} has expanded significantly " + "over the past decade. Recent studies have shown that understanding this " + "area can have far-reaching implications for both theory and practice.\n\n" + "Let me begin by outlining the three main approaches that researchers " + "have taken. The first approach focuses on quantitative analysis, " + "using large datasets to identify patterns. The second emphasises " + "qualitative methods, drawing on interviews and case studies. The third, " + "and perhaps most promising, combines both methodologies.\n\n" + "Now, I'd like to draw your attention to a landmark study published " + "in 2023 by Dr. Chen and her colleagues. Their findings suggested that " + "a combined approach yielded results that were 40% more reliable than " + "either method used in isolation." + ), + } + return {"context": transcripts.get(section_type, transcripts["social_conversation"])} + + @staticmethod + def _fallback_exercises(module, body): + exercise_types = body.get("exercise_types", ["mcq"]) + type_counts = body.get("type_counts", {}) + default_count = body.get("count_per_type", 5) + difficulty = body.get("difficulty", "B2") + questions = [] + + q_templates = { + "mcq": lambda i: { + "type": "mcq", + "instructions": "Choose the correct answer for each question.", + "prompt": f"According to the passage, what is the main idea discussed in paragraph {i + 1}?", + "options": [ + "The historical background of the topic", + "The current challenges being faced", + "Future predictions and recommendations", + "A comparison of different approaches", + ], + "correct_answer": "The current challenges being faced", + "explanation": "The paragraph primarily discusses the challenges faced in this area.", + "marks": 1, + }, + "true_false": lambda i: { + "type": "true_false", + "instructions": "Do the following statements agree with the information given in the passage? Write TRUE, FALSE, or NOT GIVEN.", + "prompt": [ + "The author supports the idea that the topic will become more important.", + "Research in this area began more than fifty years ago.", + "All experts agree on the best approach to address this issue.", + "The text mentions several countries where changes have occurred.", + "The writer believes that current measures are sufficient.", + ][i % 5], + "options": ["TRUE", "FALSE", "NOT GIVEN"], + "correct_answer": ["TRUE", "FALSE", "NOT GIVEN", "TRUE", "FALSE"][i % 5], + "explanation": "Based on the information provided in the passage.", + "marks": 1, + }, + "fill_blanks": lambda i: { + "type": "fill_blanks", + "instructions": "Complete the sentences below. Choose NO MORE THAN TWO WORDS from the passage for each answer.", + "prompt": [ + "The main factor contributing to changes in this area is ___.", + "Experts recommend that people should first focus on ___.", + "The study found that combined methods were ___ more effective.", + "Local communities have responded by implementing ___.", + "The primary concern raised by critics is the issue of ___.", + ][i % 5], + "options": [], + "correct_answer": [ + "growing awareness", "basic principles", "significantly", + "new measures", "accessibility", + ][i % 5], + "explanation": "This answer can be found in the relevant paragraph of the passage.", + "marks": 1, + }, + "matching_headings": lambda i: { + "type": "matching_headings", + "instructions": "Choose the correct heading for each paragraph from the list below.", + "prompt": f"Paragraph {i + 1}", + "options": [ + "A. An overview of the current situation", + "B. Historical development", + "C. Future challenges and opportunities", + "D. Government responses", + "E. Expert opinions and analysis", + ], + "correct_answer": ["A", "B", "C", "D", "E"][i % 5], + "explanation": f"Paragraph {i + 1} primarily deals with this topic.", + "marks": 1, + }, + "paragraph_match": lambda i: { + "type": "paragraph_match", + "instructions": "Which paragraph contains the following information?", + "prompt": [ + "a reference to research findings", + "a mention of financial concerns", + "an example from a specific country", + "a description of community initiatives", + "a prediction about the future", + ][i % 5], + "options": ["A", "B", "C", "D", "E"], + "correct_answer": ["C", "D", "B", "A", "E"][i % 5], + "explanation": "This information can be found in the specified paragraph.", + "marks": 1, + }, + } + + for et in (exercise_types or ["mcq"]): + count = int(type_counts.get(et, default_count)) + gen_fn = q_templates.get(et, q_templates["mcq"]) + for i in range(count): + q = gen_fn(i) + q["difficulty"] = difficulty + questions.append(q) + + return {"questions": questions} + + @staticmethod + def _fallback_questions(module, body): + difficulty = body.get("difficulty", "B2") + count = int(body.get("count") or body.get("question_count") or 5) + questions = [] + for i in range(count): + questions.append({ + "type": "mcq", + "prompt": f"Sample {module} question {i + 1} at {difficulty} level. " + "Which of the following best describes the main concept?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correct_answer": "Option A", + "explanation": f"This is a sample question for the {module} module.", + "difficulty": difficulty, + "marks": 1, + }) + return {"questions": questions} + # ── POST /api/exam/generation/submit — create exam from generation page ── - @http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def generation_submit(self, **kw): + from odoo.addons.encoach_api.controllers.base import validate_token + user = validate_token() + if not user: + return _json_response({"error": "Authentication required"}, 401) + request.update_env(user=user.id) body = _get_json() try: title = body.get("title", "").strip() if not title: return _json_response({"error": "title is required"}, 400) + from odoo.addons.encoach_ai.services.question_validator import ( + validate_payload, score_question, summarize_question_reports, + dumps_report, prompt_hash as _prompt_hash, + VERDICT_FAIL, VERDICT_WARN, + ) + + schema_report = validate_payload(body) + if schema_report["verdict"] == VERDICT_FAIL: + _logger.warning( + "generation_submit rejected %d schema errors: %s", + len(schema_report["errors"]), schema_report["errors"][:3], + ) + return _json_response({ + "error": "AI payload failed schema validation", + "details": schema_report, + }, 422) + label = body.get("label", "") modules = body.get("modules", {}) skip_approval = body.get("skip_approval", False) + exam_mode = body.get("exam_mode", "official") + structure_id = body.get("structure_id") + + ai_model_used = ( + body.get("ai_model") + or request.env["ir.config_parameter"].sudo().get_param( + "encoach_ai.openai_fast_model", "gpt-4o-mini" + ) + ) + payload_hash = _prompt_hash(json.dumps( + {"title": title, "modules": modules}, sort_keys=True, default=str, + )) + question_reports = [] # collected for aggregate gating below + + first_mod = next(iter(modules.values()), {}) if modules else {} + entity_val = first_mod.get("entity", "none") + entity_id = int(entity_val) if entity_val and entity_val != "none" else False + rubric_raw = first_mod.get("rubricId", "") + rubric_id = False + if rubric_raw and rubric_raw.startswith("rubric-"): + try: + rubric_id = int(rubric_raw.split("-", 1)[1]) + except (ValueError, IndexError): + pass + workflow_val = first_mod.get("approvalWorkflow", "none") + workflow_id = int(workflow_val) if workflow_val and workflow_val != "none" else 0 template_id = False try: @@ -482,42 +1353,210 @@ class AIController(http.Controller): except KeyError: return _json_response({"error": "encoach.exam.custom model not available"}, 500) - exam = Exam.sudo().create({ + initial_status = "published" if skip_approval else "draft" + exam_vals = { "title": title, + "label": label, + "exam_mode": exam_mode, "teacher_id": request.env.user.id, "template_id": template_id, - "status": "published" if skip_approval else "draft", + "status": initial_status, "total_time_min": sum(m.get("timer", 0) for m in modules.values()), + "total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()), "randomize_questions": any(m.get("shuffling", False) for m in modules.values()), - }) + "grading_system": first_mod.get("gradingSystem", "ielts"), + "access_type": first_mod.get("accessType", "private"), + "approval_workflow_id": workflow_id, + } + if entity_id: + exam_vals["entity_id"] = entity_id + if rubric_id: + exam_vals["rubric_id"] = rubric_id + if structure_id: + exam_vals["structure_id"] = int(structure_id) - try: - Section = request.env["encoach.exam.custom.section"] - seq = 10 - for mod_key, mod_data in modules.items(): - Section.sudo().create({ - "exam_id": exam.id, - "title": mod_key.capitalize(), - "skill": mod_key, - "time_limit_min": mod_data.get("timer", 0), - "scoring_method": "auto", - "sequence": seq, + exam = Exam.sudo().create(exam_vals) + + Section = request.env["encoach.exam.custom.section"].sudo() + Question = request.env["encoach.question"].sudo() + + CEFR_TO_DIFFICULTY = { + "A1": "easy", "A2": "easy", + "B1": "medium", "B2": "medium", + "C1": "hard", "C2": "hard", + } + QUESTION_TYPE_MAP = { + "mcq": "mcq", "true_false": "tfng", "fill_blanks": "gap_fill", + "matching_headings": "heading_matching", "paragraph_match": "matching", + "short_answer": "short_answer", "summary_completion": "summary_completion", + "multiple_choice": "mcq", "sentence_completion": "gap_fill", + "matching_information": "matching", "note_completion": "note_completion", + "form_completion": "form_completion", "map_labelling": "map_labelling", + } + + now = fields.Datetime.now() if hasattr(fields, 'Datetime') else None + + def _create_question(vals, *, cefr_level=None): + stem = vals.get("stem") or "" + q_report = score_question( + stem, skill=vals.get("skill"), cefr_level=cefr_level + ) + question_reports.append(q_report) + status = vals.pop("status", "draft") + if q_report["verdict"] == VERDICT_FAIL: + status = "flagged" + vals.update({ + "status": status, + "ai_model_used": ai_model_used, + "ai_prompt_hash": payload_hash, + "ai_generated_at": now, + "quality_score": q_report.get("score"), + "quality_report": dumps_report(q_report), + }) + return Question.create(vals) + + seq = 10 + total_questions = 0 + for mod_key, mod_data in modules.items(): + difficulty_list = mod_data.get("difficulty", ["B2"]) + cefr_level = difficulty_list[0] if isinstance(difficulty_list, list) and difficulty_list else "B2" + q_difficulty = CEFR_TO_DIFFICULTY.get(cefr_level, "medium") + + section = Section.create({ + "exam_id": exam.id, + "title": mod_key.capitalize(), + "skill": mod_key, + "difficulty": cefr_level, + "time_limit_min": mod_data.get("timer", 0), + "total_marks": float(mod_data.get("totalMarks", 0)), + "scoring_method": "rubric" if mod_key in ("writing", "speaking") else "auto", + "sequence": seq, + "content_json": json.dumps({ + k: mod_data[k] for k in ("passages", "sections", "tasks", "parts") + if k in mod_data and mod_data[k] + }), + }) + seq += 10 + + question_ids = [] + + def _q_difficulty_for(ex): + # Honor per-question CEFR if the AI emitted one (per-type difficulty). + ex_cefr = (ex.get("cefr_level") or "").strip().upper() + return CEFR_TO_DIFFICULTY.get(ex_cefr, q_difficulty) if ex_cefr else q_difficulty + + passages = mod_data.get("passages") or [] + for p_idx, passage in enumerate(passages): + if passage.get("text"): + section.sudo().write({"passage_text": passage["text"]}) + for ex in (passage.get("exercises") or []): + q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") + opts = ex.get("options", []) + q = _create_question({ + "skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading", + "source_type": "passage", + "question_type": q_type, + "stem": ex.get("prompt", "") or ex.get("instructions", ""), + "options": json.dumps(opts) if opts else "[]", + "correct_answer": ex.get("correct_answer", "") or "", + "marks": float(ex.get("marks", 1)), + "difficulty": _q_difficulty_for(ex), + "status": "active", + "ai_generated": True, + }, cefr_level=(ex.get("cefr_level") or cefr_level)) + question_ids.append(q.id) + + sections_data = mod_data.get("sections") or [] + for s_data in sections_data: + if s_data.get("context"): + section.sudo().write({"passage_text": s_data["context"]}) + for ex in (s_data.get("exercises") or []): + q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") + opts = ex.get("options", []) + q = _create_question({ + "skill": "listening", + "source_type": "audio", + "question_type": q_type, + "stem": ex.get("prompt", "") or ex.get("instructions", ""), + "options": json.dumps(opts) if opts else "[]", + "correct_answer": ex.get("correct_answer", "") or "", + "marks": float(ex.get("marks", 1)), + "difficulty": _q_difficulty_for(ex), + "status": "active", + "ai_generated": True, + }, cefr_level=(ex.get("cefr_level") or cefr_level)) + question_ids.append(q.id) + + tasks = mod_data.get("tasks") or [] + for t_idx, task in enumerate(tasks): + q = _create_question({ + "skill": "writing", + "source_type": "writing_prompt", + "question_type": "short_answer", + "stem": task.get("instructions", f"Writing Task {t_idx + 1}"), + "options": "[]", + "correct_answer": "", + "marks": float(task.get("marks", 0)), + "difficulty": q_difficulty, + "status": "active", + "ai_generated": True, + }, cefr_level=cefr_level) + question_ids.append(q.id) + + parts = mod_data.get("parts") or [] + for p_idx, part in enumerate(parts): + q = _create_question({ + "skill": "speaking", + "source_type": "speaking_card", + "question_type": "short_answer", + "stem": part.get("script", f"Speaking Part {p_idx + 1}"), + "options": "[]", + "correct_answer": "", + "marks": float(part.get("marks", 0)), + "difficulty": q_difficulty, + "status": "active", + "ai_generated": True, + }, cefr_level=cefr_level) + question_ids.append(q.id) + + if question_ids: + section.sudo().write({ + "question_ids": [(6, 0, question_ids)], + "question_count": len(question_ids), }) - seq += 10 - except KeyError: - pass + total_questions += len(question_ids) + + quality_summary = summarize_question_reports(question_reports) + if ( + exam.status != "draft" + and quality_summary["verdict"] in (VERDICT_FAIL, VERDICT_WARN) + and quality_summary["total"] > 0 + ): + exam.sudo().write({"status": "pending_review"}) + _logger.info( + "exam %s forced pending_review: avg_score=%.2f failed=%d warned=%d", + exam.id, quality_summary["avg_score"], + quality_summary["failed"], quality_summary["warned"], + ) return _json_response({ "exam_id": exam.id, "status": exam.status, "template_id": template_id, + "total_questions": total_questions, + "quality": quality_summary, + "schema_validation": { + "verdict": schema_report["verdict"], + "warnings": schema_report["warnings"], + }, }, 201) except Exception as e: _logger.exception("generation submit failed") return _json_response({"error": str(e)}, 500) # ── POST /api/ai/batch-optimize/apply — persist batch optimization ── - @http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/batch-optimize/apply", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_batch_optimize_apply(self, **kw): body = _get_json() optimized = body.get("optimized", []) @@ -532,8 +1571,14 @@ class AIController(http.Controller): return _json_response({"applied": 0, "error": str(e)}, 500) # ── POST /api/exam//generate/save — save generated exam items ── - @http.route("/api/exam//generate/save", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam//generate/save", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def exam_generate_save(self, module, **kw): + from odoo.addons.encoach_api.controllers.base import validate_token + user = validate_token() + if not user: + return _json_response({"error": "Authentication required"}, 401) + request.update_env(user=user.id) body = _get_json() questions = body.get("questions", []) saved = 0 @@ -567,7 +1612,8 @@ class AIController(http.Controller): return _json_response({"saved": 0, "error": str(e)}, 500) # ── POST /api/workbench/suggest-materials — AI material suggestions ── - @http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/suggest-materials", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_suggest_materials(self, **kw): body = _get_json() try: @@ -587,7 +1633,8 @@ class AIController(http.Controller): return _json_response({"materials": [], "error": str(e)}) # ── Topic content generation — adaptive ── - @http.route("/api/topics//generate-content", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/topics//generate-content", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def topic_generate_content(self, topic_id, **kw): body = _get_json() try: diff --git a/backend/custom_addons/encoach_ai/controllers/coach_controller.py b/backend/custom_addons/encoach_ai/controllers/coach_controller.py index 47e5a196..340d001f 100644 --- a/backend/custom_addons/encoach_ai/controllers/coach_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/coach_controller.py @@ -4,6 +4,10 @@ import json import logging from odoo import http from odoo.http import request, Response +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response as _base_json_response, _get_json_body, +) + _logger = logging.getLogger(__name__) @@ -27,7 +31,8 @@ class CoachController(http.Controller): return CoachService(request.env) # ── POST /api/coach/chat — AiAssistantDrawer.tsx ── - @http.route("/api/coach/chat", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_chat(self, **kw): body = _get_json() try: @@ -43,7 +48,8 @@ class CoachController(http.Controller): return _json_response({"reply": f"I'm having trouble right now. Error: {e}"}) # ── GET /api/coach/tip — AiTipBanner.tsx ── - @http.route("/api/coach/tip", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/coach/tip", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def coach_tip(self, **kw): context = request.params.get("context", "general") try: @@ -53,7 +59,8 @@ class CoachController(http.Controller): return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"}) # ── POST /api/coach/explain — AiGradeExplainer.tsx ── - @http.route("/api/coach/explain", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/explain", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_explain(self, **kw): body = _get_json() try: @@ -67,7 +74,8 @@ class CoachController(http.Controller): return _json_response({"explanation": f"Could not generate explanation: {e}"}) # ── POST /api/coach/suggest — AiStudyCoach.tsx ── - @http.route("/api/coach/suggest", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/suggest", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_suggest(self, **kw): body = _get_json() try: @@ -82,7 +90,8 @@ class CoachController(http.Controller): }) # ── POST /api/coach/writing-help — AiWritingHelper.tsx ── - @http.route("/api/coach/writing-help", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/writing-help", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_writing_help(self, **kw): body = _get_json() try: @@ -97,7 +106,8 @@ class CoachController(http.Controller): return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]}) # ── POST /api/coach/hint — (unused component, wired for completeness) ── - @http.route("/api/coach/hint", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/hint", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_hint(self, **kw): body = _get_json() try: diff --git a/backend/custom_addons/encoach_ai/controllers/feedback_controller.py b/backend/custom_addons/encoach_ai/controllers/feedback_controller.py new file mode 100644 index 00000000..2cf2cd24 --- /dev/null +++ b/backend/custom_addons/encoach_ai/controllers/feedback_controller.py @@ -0,0 +1,217 @@ +"""Endpoints for submitting & aggregating AI feedback.""" + +import logging + +from odoo import fields, http +from odoo.http import request + +from odoo.addons.encoach_api.controllers.base import ( + _error_response, + _get_json_body, + _json_response, + _paginate, + jwt_required, +) + +_logger = logging.getLogger(__name__) + + +def _feedback_to_dict(row): + return { + "id": row.id, + "subject_type": row.subject_type, + "subject_id": row.subject_id, + "subject_key": row.subject_key, + "rating": row.rating, + "comment": row.comment or "", + "tags": (row.tags or "").split(",") if row.tags else [], + "prompt_key": row.prompt_key or None, + "prompt_version": row.prompt_version or None, + "ai_log_id": row.ai_log_id.id if row.ai_log_id else None, + "user_id": row.user_id.id, + "user_name": row.user_id.name, + "entity_id": row.entity_id.id if row.entity_id else None, + "course_id": row.course_id.id if row.course_id else None, + "status": row.status, + "create_date": ( + fields.Datetime.to_string(row.create_date) if row.create_date else None + ), + "resolved_at": ( + fields.Datetime.to_string(row.resolved_at) if row.resolved_at else None + ), + "resolution_notes": row.resolution_notes or "", + } + + +class EncoachAIFeedbackController(http.Controller): + """Feedback write + admin triage endpoints.""" + + # ------------------------------------------------------------------ + # POST /api/ai/feedback — student submits/updates feedback + # + # Upsert semantics: we enforce uniqueness per (user, subject_type, + # subject_id) at the DB layer, so the client doesn't need to know whether + # the row already exists. + # ------------------------------------------------------------------ + @http.route("/api/ai/feedback", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required + def submit(self, **kw): + try: + body = _get_json_body() or {} + subject_type = body.get("subject_type") + subject_id = body.get("subject_id") + rating = body.get("rating") + if subject_type not in { + "question", "coach", "explanation", "translation", "narrative", "other", + }: + return _error_response("Invalid subject_type", 400) + if not isinstance(subject_id, int) or subject_id <= 0: + return _error_response("subject_id must be a positive integer", 400) + if rating not in {"up", "down"}: + return _error_response("rating must be 'up' or 'down'", 400) + comment = body.get("comment") or "" + if rating == "down" and not (comment or "").strip(): + # We keep this a soft constraint so the UI can decide how + # strict to be; returning an error here gives clients a clear + # path to prompt the user when needed. + return _error_response( + "A short comment is required when rating is 'down'.", 400, + ) + + vals = { + "subject_type": subject_type, + "subject_id": subject_id, + "rating": rating, + "comment": comment, + "tags": ( + ",".join(body.get("tags") or []) + if isinstance(body.get("tags"), list) + else (body.get("tags") or "") + ), + "prompt_key": body.get("prompt_key") or False, + "prompt_version": body.get("prompt_version") or 0, + "ai_log_id": body.get("ai_log_id") or False, + "entity_id": body.get("entity_id") or False, + "course_id": body.get("course_id") or False, + "user_id": request.env.user.id, + } + + Feedback = request.env["encoach.ai.feedback"].sudo() + existing = Feedback.search([ + ("user_id", "=", request.env.user.id), + ("subject_type", "=", subject_type), + ("subject_id", "=", subject_id), + ], limit=1) + with request.env.cr.savepoint(): + if existing: + existing.write(vals) + row = existing + else: + row = Feedback.create(vals) + return _json_response(_feedback_to_dict(row), 200 if existing else 201) + except Exception as e: + _logger.exception("ai feedback submit failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/feedback/summary?subject_type=question&subject_id=42 + # ------------------------------------------------------------------ + @http.route( + "/api/ai/feedback/summary", type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def summary(self, subject_type=None, subject_id=None, **kw): + try: + if not subject_type or not subject_id: + return _error_response("subject_type and subject_id are required", 400) + Feedback = request.env["encoach.ai.feedback"].sudo() + domain = [ + ("subject_type", "=", subject_type), + ("subject_id", "=", int(subject_id)), + ] + rows = Feedback.search(domain) + up = sum(1 for r in rows if r.rating == "up") + down = sum(1 for r in rows if r.rating == "down") + my_row = Feedback.search([ + *domain, + ("user_id", "=", request.env.user.id), + ], limit=1) + return _json_response({ + "subject_type": subject_type, + "subject_id": int(subject_id), + "up": up, + "down": down, + "total": up + down, + "my_rating": my_row.rating if my_row else None, + "my_comment": my_row.comment or "" if my_row else "", + }) + except Exception as e: + _logger.exception("ai feedback summary failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/feedback — admin triage list + # ------------------------------------------------------------------ + @http.route("/api/ai/feedback", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required + def list_feedback(self, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + Feedback = request.env["encoach.ai.feedback"].sudo() + domain = [] + if kw.get("status"): + domain.append(("status", "=", kw["status"])) + if kw.get("rating"): + domain.append(("rating", "=", kw["rating"])) + if kw.get("subject_type"): + domain.append(("subject_type", "=", kw["subject_type"])) + if kw.get("prompt_key"): + domain.append(("prompt_key", "=", kw["prompt_key"])) + offset, limit, page = _paginate(kw) + total = Feedback.search_count(domain) + rows = Feedback.search(domain, offset=offset, limit=limit, order="create_date desc") + items = [_feedback_to_dict(r) for r in rows] + return _json_response({ + "items": items, + "data": items, + "total": total, + "page": page, + "size": limit, + }) + except Exception as e: + _logger.exception("ai feedback list failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/feedback//resolve — admin triage + # ------------------------------------------------------------------ + @http.route( + '/api/ai/feedback//resolve', + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def resolve(self, feedback_id, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + body = _get_json_body() or {} + status = body.get("status") + if status not in {"acknowledged", "fixed", "dismissed"}: + return _error_response( + "status must be one of acknowledged|fixed|dismissed", 400, + ) + row = request.env["encoach.ai.feedback"].sudo().browse(feedback_id) + if not row.exists(): + return _error_response("Feedback not found", 404) + with request.env.cr.savepoint(): + row.write({ + "status": status, + "resolved_by_id": request.env.user.id, + "resolved_at": fields.Datetime.now(), + "resolution_notes": body.get("notes") or "", + }) + return _json_response(_feedback_to_dict(row)) + except Exception as e: + _logger.exception("ai feedback resolve failed") + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_ai/controllers/media_controller.py b/backend/custom_addons/encoach_ai/controllers/media_controller.py index e470cb23..fa5b03ee 100644 --- a/backend/custom_addons/encoach_ai/controllers/media_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/media_controller.py @@ -5,6 +5,10 @@ import json import logging from odoo import http from odoo.http import request, Response +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response as _base_json_response, _get_json_body, +) + _logger = logging.getLogger(__name__) @@ -52,7 +56,8 @@ class MediaController(http.Controller): ) # ── POST /api/exam/listening/media — generate listening audio ── - @http.route("/api/exam/listening/media", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/listening/media", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def listening_media(self, **kw): body = _get_json() text = body.get("text", "") @@ -72,7 +77,8 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── POST /api/exam/speaking/media — generate speaking prompt audio ── - @http.route("/api/exam/speaking/media", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/speaking/media", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def speaking_media(self, **kw): body = _get_json() text = body.get("text", "") @@ -89,7 +95,8 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── GET /api/exam/avatars — list ELAI avatars ── - @http.route("/api/exam/avatars", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/exam/avatars", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def list_avatars(self, **kw): try: from odoo.addons.encoach_ai.services.elai_service import ElaiService @@ -100,7 +107,8 @@ class MediaController(http.Controller): return _json_response({"avatars": [], "note": str(e)}) # ── POST /api/exam/avatar/video — create avatar video ── - @http.route("/api/exam/avatar/video", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/avatar/video", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def create_avatar_video(self, **kw): body = _get_json() try: @@ -116,7 +124,8 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── GET /api/exam/avatar/video/:id — check video status ── - @http.route("/api/exam/avatar/video/", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/exam/avatar/video/", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def video_status(self, video_id, **kw): try: from odoo.addons.encoach_ai.services.elai_service import ElaiService @@ -126,7 +135,8 @@ class MediaController(http.Controller): return _json_response({"video_id": video_id, "status": "error", "error": str(e)}) # ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ── - @http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/courses/ai-generate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_generate_course(self, **kw): body = _get_json() try: diff --git a/backend/custom_addons/encoach_ai/controllers/prompt_controller.py b/backend/custom_addons/encoach_ai/controllers/prompt_controller.py new file mode 100644 index 00000000..63e861fa --- /dev/null +++ b/backend/custom_addons/encoach_ai/controllers/prompt_controller.py @@ -0,0 +1,219 @@ +"""Admin endpoints for the versioned AI prompt editor.""" + +import logging + +from odoo import fields, http +from odoo.http import request + +from odoo.addons.encoach_api.controllers.base import ( + _error_response, + _get_json_body, + _json_response, + _paginate, + jwt_required, +) + +_logger = logging.getLogger(__name__) + + +def _prompt_to_dict(prompt, *, include_content=True): + data = { + "id": prompt.id, + "key": prompt.key, + "version": prompt.version, + "title": prompt.title or "", + "description": prompt.description or "", + "is_active": bool(prompt.is_active), + "variables": (prompt.variables or "").split(",") if prompt.variables else [], + "author_id": prompt.author_id.id if prompt.author_id else None, + "author_name": prompt.author_id.name if prompt.author_id else "", + "activated_at": ( + fields.Datetime.to_string(prompt.activated_at) if prompt.activated_at else None + ), + "create_date": ( + fields.Datetime.to_string(prompt.create_date) if prompt.create_date else None + ), + } + if include_content: + data["content"] = prompt.content or "" + return data + + +class EncoachAIPromptController(http.Controller): + """Versioned prompt CRUD. + + Auth: every endpoint requires a valid JWT. The write endpoints additionally + check for admin rights via ``res.users.has_group('base.group_system')`` so + non-admins can read prompts but not mutate them. + """ + + # ------------------------------------------------------------------ + # GET /api/ai/prompts — list keys (latest version per key) + # ------------------------------------------------------------------ + @http.route("/api/ai/prompts", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required + def list_keys(self, **kw): + try: + Prompt = request.env["encoach.ai.prompt"].sudo() + # One row per key = the latest version. We fetch candidates then + # deduplicate in Python because Odoo's ORM doesn't expose DISTINCT + # ON ergonomically. + search = (kw.get("search") or "").strip() + domain = [("key", "ilike", search)] if search else [] + # Sort by key then descending version so the first occurrence per + # key is the latest. + all_prompts = Prompt.search(domain, order="key asc, version desc") + seen = set() + latest = [] + for p in all_prompts: + if p.key in seen: + continue + seen.add(p.key) + latest.append(p) + offset, per_page, page = _paginate(kw) + total = len(latest) + window = latest[offset : offset + per_page] + items = [_prompt_to_dict(p, include_content=False) for p in window] + # Attach version-count for the UI to show "v5 (of 7)" hints. + counts = {} + for p in all_prompts: + counts[p.key] = counts.get(p.key, 0) + 1 + for entry in items: + entry["total_versions"] = counts.get(entry["key"], 1) + return _json_response({ + "items": items, + "data": items, + "total": total, + "page": page, + "size": per_page, + }) + except Exception as e: + _logger.exception("list ai prompts failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/prompts//versions + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts//versions', + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def list_versions(self, key, **kw): + try: + Prompt = request.env["encoach.ai.prompt"].sudo() + prompts = Prompt.search([("key", "=", key)], order="version desc") + if not prompts: + return _error_response("Prompt key not found", 404) + items = [_prompt_to_dict(p, include_content=False) for p in prompts] + return _json_response({ + "key": key, + "items": items, + "data": items, + "total": len(items), + "page": 1, + "size": len(items), + }) + except Exception as e: + _logger.exception("list prompt versions failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/prompts/ + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts/', + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def get_version(self, prompt_id, **kw): + try: + prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id) + if not prompt.exists(): + return _error_response("Prompt not found", 404) + return _json_response(_prompt_to_dict(prompt)) + except Exception as e: + _logger.exception("get prompt failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/prompts — create a new version of a key (or a new key) + # ------------------------------------------------------------------ + @http.route("/api/ai/prompts", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required + def create_version(self, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + + body = _get_json_body() or {} + key = (body.get("key") or "").strip() + content = body.get("content") or "" + title = (body.get("title") or "").strip() + if not key or not content.strip() or not title: + return _error_response( + "key, title, and content are required", 400, + ) + + vals = { + "key": key, + "title": title, + "description": body.get("description") or "", + "content": content, + "is_active": bool(body.get("activate", False)), + } + with request.env.cr.savepoint(): + prompt = request.env["encoach.ai.prompt"].sudo().create(vals) + return _json_response(_prompt_to_dict(prompt), 201) + except Exception as e: + _logger.exception("create prompt version failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/prompts//activate + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts//activate', + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def activate(self, prompt_id, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id) + if not prompt.exists(): + return _error_response("Prompt not found", 404) + with request.env.cr.savepoint(): + prompt.write({"is_active": True}) + return _json_response(_prompt_to_dict(prompt)) + except Exception as e: + _logger.exception("activate prompt failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/prompts//render — dry-run render with sample variables + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts//render', + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def render_preview(self, prompt_id, **kw): + try: + prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id) + if not prompt.exists(): + return _error_response("Prompt not found", 404) + body = _get_json_body() or {} + variables = body.get("variables") or {} + if not isinstance(variables, dict): + return _error_response("variables must be a JSON object", 400) + rendered = prompt.render(variables) + return _json_response({ + "rendered": rendered, + "key": prompt.key, + "version": prompt.version, + }) + except Exception as e: + _logger.exception("render prompt failed") + return _error_response(str(e), 400) diff --git a/backend/custom_addons/encoach_ai/models/__init__.py b/backend/custom_addons/encoach_ai/models/__init__.py index 69ddeed0..3f35177f 100644 --- a/backend/custom_addons/encoach_ai/models/__init__.py +++ b/backend/custom_addons/encoach_ai/models/__init__.py @@ -1,2 +1,5 @@ from . import ai_settings from . import ai_log +from . import ai_prompt +from . import ai_feedback +from . import constants diff --git a/backend/custom_addons/encoach_ai/models/ai_feedback.py b/backend/custom_addons/encoach_ai/models/ai_feedback.py new file mode 100644 index 00000000..ea5cf00d --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/ai_feedback.py @@ -0,0 +1,134 @@ +"""Student feedback on AI-generated content (thumbs up/down). + +This closes the AI quality loop started by the human-review workflow (P3.3) +and the versioned prompts (P3.4): + +* Every AI-generated artefact (a question, a coach reply, an explanation, a + translation, ...) can have any number of feedback rows attached to it. +* Feedback is always coupled to a **subject type + subject id** so it can + point at anything (``encoach.question``, ``encoach.ai.log``, ...) without + forcing a hard FK per model. +* When the student flags feedback as negative, we also store their free-text + note so product can triage. + +Downstream consumers (prompt library, RAG indexers, report dashboards) can +read ``encoach.ai.feedback`` to compute win rates per prompt key, per model, +per entity, etc. +""" + +from __future__ import annotations + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class EncoachAIFeedback(models.Model): + _name = "encoach.ai.feedback" + _description = "Student feedback on AI-generated content" + _order = "create_date desc" + _rec_name = "subject_key" + + # ------------------------------------------------------------------ + # What was rated + # ------------------------------------------------------------------ + subject_type = fields.Selection( + [ + ("question", "Exam question"), + ("coach", "AI Coach reply"), + ("explanation", "Content explanation"), + ("translation", "Translation"), + ("narrative", "Report narrative"), + ("other", "Other AI output"), + ], + required=True, index=True, + ) + subject_id = fields.Integer( + required=True, index=True, + help="Id of the rated artefact (semantics depend on subject_type).", + ) + subject_key = fields.Char( + compute="_compute_subject_key", store=True, index=True, + help="Denormalised ':' key for quick lookups/aggregations.", + ) + + # Optional link to the AI call that produced this artefact. When set, it + # lets us correlate feedback with the underlying model/prompt used. + ai_log_id = fields.Many2one( + "encoach.ai.log", ondelete="set null", index=True, + ) + prompt_key = fields.Char( + index=True, + help="If the artefact was produced via encoach.ai.prompt, record the " + "key here so we can compute win-rates per prompt.", + ) + prompt_version = fields.Integer() + + # ------------------------------------------------------------------ + # Rating + # ------------------------------------------------------------------ + rating = fields.Selection( + [("up", "Thumbs up"), ("down", "Thumbs down")], + required=True, index=True, + ) + comment = fields.Text( + help="Free-text note. Required when rating is 'down' and the UI " + "prompts the student for a reason.", + ) + tags = fields.Char( + help="Comma-separated tags (e.g. 'wrong-answer,confusing-stem').", + ) + + # ------------------------------------------------------------------ + # Who / context + # ------------------------------------------------------------------ + user_id = fields.Many2one( + "res.users", required=True, index=True, + default=lambda self: self.env.user, + ) + entity_id = fields.Many2one( + "encoach.entity", ondelete="set null", index=True, + ) + course_id = fields.Many2one( + "encoach.course", ondelete="set null", index=True, + ) + + # ------------------------------------------------------------------ + # Triage status (filled in by admins/coaches reviewing feedback). + # ------------------------------------------------------------------ + status = fields.Selection( + [ + ("open", "Open"), + ("acknowledged", "Acknowledged"), + ("fixed", "Fixed"), + ("dismissed", "Dismissed"), + ], + default="open", index=True, + ) + resolved_by_id = fields.Many2one( + "res.users", ondelete="set null", + ) + resolved_at = fields.Datetime() + resolution_notes = fields.Text() + + # ------------------------------------------------------------------ + # Constraints + # ------------------------------------------------------------------ + _sql_constraints = [ + ( + "uniq_user_subject", + "unique(user_id, subject_type, subject_id)", + "A user can only leave one feedback row per AI artefact. " + "Subsequent submissions should update the existing row.", + ), + ] + + @api.depends("subject_type", "subject_id") + def _compute_subject_key(self): + for rec in self: + rec.subject_key = f"{rec.subject_type or ''}:{rec.subject_id or 0}" + + @api.constrains("subject_type", "subject_id") + def _check_subject(self): + for rec in self: + if not rec.subject_type or not rec.subject_id: + raise ValidationError("subject_type and subject_id are required.") diff --git a/backend/custom_addons/encoach_ai/models/ai_prompt.py b/backend/custom_addons/encoach_ai/models/ai_prompt.py new file mode 100644 index 00000000..8d711941 --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/ai_prompt.py @@ -0,0 +1,213 @@ +"""Versioned AI prompt templates. + +Context (ADR-0004, P3.4 hardening release): we want non-engineers to iterate +on LLM prompts without shipping code. Every time an author edits a prompt we +keep the previous revision around so we can A/B test, audit, and roll back. + +Design: + +* A **prompt** is identified by its ``key`` (e.g. ``exam.mcq.generate``) — a + stable, dotted string that callers reference from Python. +* Each save produces a new **version** row (bumped ``version`` int). The old + rows stay with ``is_active = False`` so history is preserved; only one row + per ``key`` can be active at a time (enforced in ``write``/``create``). +* Callers look the prompt up with ``get_active(key)`` and render it with + ``render(variables)``. Rendering uses :py:meth:`str.format_map` so templates + are just ``{variable}`` placeholders — no new DSL to learn. + +This module intentionally does **not** call the LLM itself. It is a +content-management layer that the existing ``encoach_ai.services.*`` pipelines +can adopt at their own pace (falling back to their hard-coded strings until a +prompt with the matching key is created in the UI). +""" + +from __future__ import annotations + +import logging +import re +from typing import Iterable + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + +# Keys look like ``namespace.sub_namespace.name`` — lowercase, dotted. +_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") +# Variables pulled from ``{foo}`` / ``{foo.bar}`` placeholders. +_VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}") + + +class EncoachAIPrompt(models.Model): + _name = "encoach.ai.prompt" + _description = "Versioned AI prompt template" + _order = "key, version desc" + _rec_name = "display_name" + + key = fields.Char( + required=True, + index=True, + help="Stable dotted identifier (e.g. 'exam.mcq.generate'). " + "Callers reference this from Python.", + ) + version = fields.Integer(required=True, default=1, index=True) + title = fields.Char( + required=True, + help="Short human label shown in the admin editor.", + ) + description = fields.Text( + help="Author intent, expected variables, known limitations.", + ) + content = fields.Text( + required=True, + help="Template body. Use {variable} placeholders; " + "render() fills them via str.format_map.", + ) + is_active = fields.Boolean( + default=False, index=True, + help="Exactly one active row per key. Creating/activating a new row " + "automatically deactivates the previous active version.", + ) + author_id = fields.Many2one("res.users", ondelete="set null", readonly=True) + activated_at = fields.Datetime(readonly=True) + + # Denormalised for quick display/search. Recomputed on save. + variables = fields.Char( + compute="_compute_variables", store=True, + help="Comma-separated list of {variable} placeholders found in content.", + ) + display_name = fields.Char( + compute="_compute_display_name", store=True, + ) + + _sql_constraints = [ + ( + "key_version_uniq", + "unique(key, version)", + "A prompt version must be unique for its key.", + ), + ] + + # ------------------------------------------------------------------ + # Computed fields + # ------------------------------------------------------------------ + @api.depends("content") + def _compute_variables(self): + for rec in self: + if not rec.content: + rec.variables = "" + continue + found = sorted(set(_VAR_RE.findall(rec.content))) + rec.variables = ",".join(found) + + @api.depends("key", "version", "title") + def _compute_display_name(self): + for rec in self: + rec.display_name = f"{rec.key} v{rec.version} — {rec.title or ''}".rstrip(" —") + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + @api.constrains("key") + def _check_key_shape(self): + for rec in self: + if not rec.key or not _KEY_RE.match(rec.key): + raise ValidationError( + f"Invalid prompt key {rec.key!r}. " + "Use lowercase dotted identifiers like 'exam.mcq.generate'." + ) + + @api.constrains("content") + def _check_content_non_empty(self): + for rec in self: + if not (rec.content or "").strip(): + raise ValidationError("Prompt content cannot be empty.") + + # ------------------------------------------------------------------ + # Create / write — enforce "one active per key" + # ------------------------------------------------------------------ + @api.model_create_multi + def create(self, vals_list): + # Default version = max(existing) + 1 when caller omits it. + for vals in vals_list: + if "version" not in vals or not vals.get("version"): + key = vals.get("key") + existing = self.search( + [("key", "=", key)], order="version desc", limit=1, + ) + vals["version"] = (existing.version + 1) if existing else 1 + vals.setdefault("author_id", self.env.user.id) + if vals.get("is_active"): + vals.setdefault("activated_at", fields.Datetime.now()) + records = super().create(vals_list) + # Post-create: enforce exclusivity for any rows created as active. + for rec in records: + if rec.is_active: + rec._deactivate_siblings() + return records + + def write(self, vals): + res = super().write(vals) + if vals.get("is_active"): + now = fields.Datetime.now() + for rec in self: + if rec.is_active and not rec.activated_at: + super(EncoachAIPrompt, rec).write({"activated_at": now}) + rec._deactivate_siblings() + return res + + def _deactivate_siblings(self): + for rec in self: + if not rec.is_active: + continue + siblings = self.search([ + ("key", "=", rec.key), + ("id", "!=", rec.id), + ("is_active", "=", True), + ]) + if siblings: + siblings.write({"is_active": False}) + + # ------------------------------------------------------------------ + # Public API (called by pipelines / controllers) + # ------------------------------------------------------------------ + @api.model + def get_active(self, key: str): + """Return the active prompt record for ``key`` (or an empty set).""" + return self.sudo().search( + [("key", "=", key), ("is_active", "=", True)], limit=1, + ) + + def render(self, variables: dict | None = None) -> str: + """Fill ``{placeholders}`` using ``variables`` (dict).""" + self.ensure_one() + variables = variables or {} + missing = self._missing_variables(variables.keys()) + if missing: + raise UserError( + f"Missing prompt variables for {self.key} v{self.version}: " + f"{', '.join(sorted(missing))}" + ) + try: + return self.content.format_map(_SafeFormatDict(variables)) + except Exception as exc: + _logger.exception("prompt render failed for %s v%s", self.key, self.version) + raise UserError(f"Prompt render failed: {exc}") from exc + + def _missing_variables(self, provided: Iterable[str]) -> set[str]: + self.ensure_one() + required = set(_VAR_RE.findall(self.content or "")) + provided_root = {p.split(".")[0] for p in provided} + return {v for v in required if v.split(".")[0] not in provided_root} + + +class _SafeFormatDict(dict): + """Dict that returns ``''`` for dotted/nested lookups str.format can't resolve. + + We don't advertise ``{foo.bar}`` as supported (the editor shows top-level + variables only), but we also don't want rendering to explode if a caller + passes an object with attributes. + """ + + def __missing__(self, key): + return "{" + key + "}" diff --git a/backend/custom_addons/encoach_ai/models/constants.py b/backend/custom_addons/encoach_ai/models/constants.py new file mode 100644 index 00000000..75004c66 --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/constants.py @@ -0,0 +1,13 @@ +CEFR_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + +GPT_MODELS = { + 'default': 'gpt-4o', + 'fast': 'gpt-4o-mini', +} + +TEMPERATURE = 0.7 + +TOPICS = [ + 'Education', 'Technology', 'Health', 'Environment', 'Culture', + 'Travel', 'Science', 'Business', 'Sports', 'Society', +] diff --git a/backend/custom_addons/encoach_ai/security/ir.model.access.csv b/backend/custom_addons/encoach_ai/security/ir.model.access.csv index 1c0fd811..958f4e13 100644 --- a/backend/custom_addons/encoach_ai/security/ir.model.access.csv +++ b/backend/custom_addons/encoach_ai/security/ir.model.access.csv @@ -1,3 +1,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_ai_log_admin,encoach.ai.log admin,model_encoach_ai_log,base.group_system,1,1,1,1 access_ai_log_user,encoach.ai.log user,model_encoach_ai_log,base.group_user,1,0,1,0 +access_ai_prompt_admin,encoach.ai.prompt admin,model_encoach_ai_prompt,base.group_system,1,1,1,1 +access_ai_prompt_user,encoach.ai.prompt user,model_encoach_ai_prompt,base.group_user,1,0,0,0 +access_ai_feedback_admin,encoach.ai.feedback admin,model_encoach_ai_feedback,base.group_system,1,1,1,1 +access_ai_feedback_user,encoach.ai.feedback user,model_encoach_ai_feedback,base.group_user,1,1,1,0 diff --git a/backend/custom_addons/encoach_ai/services/__init__.py b/backend/custom_addons/encoach_ai/services/__init__.py index 80fcb889..2e7b8ce7 100644 --- a/backend/custom_addons/encoach_ai/services/__init__.py +++ b/backend/custom_addons/encoach_ai/services/__init__.py @@ -5,3 +5,5 @@ from .elevenlabs_service import ElevenLabsService from .gptzero_service import GPTZeroService from .elai_service import ElaiService from .coach_service import CoachService +from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9) +from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1) diff --git a/backend/custom_addons/encoach_ai/services/cefr_mapper.py b/backend/custom_addons/encoach_ai/services/cefr_mapper.py new file mode 100644 index 00000000..dd1309d7 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/cefr_mapper.py @@ -0,0 +1,149 @@ +"""Canonical CEFR / IELTS band / IRT theta mapper. + +This is the SINGLE SOURCE OF TRUTH for band ↔ CEFR ↔ theta conversions across +the platform. All modules MUST import from here rather than hand-rolling their +own mapping tables. See §21 of docs/PROJECT_SUMMARY.md for the rationale. +""" + + +# IRT theta band thresholds (ability parameter → CEFR level) +THETA_TO_CEFR = [ + (-4.0, -2.5, 'pre_a1'), + (-2.5, -1.5, 'a1'), + (-1.5, -0.5, 'a2'), + (-0.5, 0.5, 'b1'), + (0.5, 1.5, 'b2'), + (1.5, 2.5, 'c1'), + (2.5, 4.0, 'c2'), +] + +# CEFR code → canonical IELTS band centre value +CEFR_TO_BAND = { + 'pre_a1': 2.0, + 'a1': 3.0, + 'a2': 4.0, + 'b1': 5.0, + 'b2': 6.5, + 'c1': 7.5, + 'c2': 9.0, +} + +# Lowercase CEFR code → human-readable label +CEFR_LABELS = { + 'pre_a1': 'Pre-A1 (Beginner)', + 'a1': 'A1 (Elementary)', + 'a2': 'A2 (Pre-Intermediate)', + 'b1': 'B1 (Intermediate)', + 'b2': 'B2 (Upper-Intermediate)', + 'c1': 'C1 (Advanced)', + 'c2': 'C2 (Proficient)', +} + +# Monotonic (highest band → lowest) thresholds used by band_to_cefr. +# Picked to align CEFR_TO_BAND with widely-published IELTS/CEFR crosswalks +# (Cambridge, Council of Europe): 8.0+ → C2, 7.0-7.9 → C1, 5.5-6.9 → B2, etc. +_BAND_THRESHOLDS = [ + (8.0, 'c2'), + (7.0, 'c1'), + (5.5, 'b2'), + (4.5, 'b1'), + (3.5, 'a2'), + (2.5, 'a1'), + (0.0, 'pre_a1'), +] + + +def band_to_cefr(band): + """Map an IELTS band (0-9, may be float) to a canonical CEFR code.""" + if band is None: + return None + try: + b = float(band) + except (TypeError, ValueError): + return None + for threshold, level in _BAND_THRESHOLDS: + if b >= threshold: + return level + return 'pre_a1' + + +def theta_to_cefr(theta): + """Map an IRT theta ability parameter (typically -4..+4) to a CEFR code.""" + try: + t = float(theta) + except (TypeError, ValueError): + return 'pre_a1' + for low, high, level in THETA_TO_CEFR: + if low <= t < high: + return level + return 'c2' if t >= 2.5 else 'pre_a1' + + +def theta_to_band(theta): + """Interpolate an IRT theta to an IELTS band, rounded to nearest 0.5.""" + try: + t = float(theta) + except (TypeError, ValueError): + return 0.0 + cefr = theta_to_cefr(t) + base_band = CEFR_TO_BAND.get(cefr, 5.0) + for low, high, level in THETA_TO_CEFR: + if level == cefr: + width = high - low + position = (t - low) / width if width > 0 else 0.5 + cefr_list = list(CEFR_TO_BAND.keys()) + idx = cefr_list.index(cefr) + next_band = CEFR_TO_BAND.get( + cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0 + ) + band = base_band + position * (next_band - base_band) + return round(band * 2) / 2 + return base_band + + +def cefr_to_band(cefr): + """Return the canonical centre-of-range band for a CEFR code.""" + if not cefr: + return None + return CEFR_TO_BAND.get(str(cefr).strip().lower().replace('-', '_')) + + +def normalize_cefr(label): + """Canonicalise a CEFR label to the uppercase display form (e.g. 'A2', 'Pre-A1'). + + Accepts 'a2', 'A2', 'pre_a1', 'Pre-A1', 'pre a1' etc. Returns ``None`` + if the input is falsy; returns the input unchanged if unrecognised. + """ + if not label: + return None + s = str(label).strip().lower().replace('-', '_').replace(' ', '_') + return { + 'pre_a1': 'Pre-A1', + 'a1': 'A1', + 'a2': 'A2', + 'b1': 'B1', + 'b2': 'B2', + 'c1': 'C1', + 'c2': 'C2', + }.get(s, label) + + +def cefr_label(cefr_code): + """Return the human-readable label (e.g. 'B2 (Upper-Intermediate)') for a code.""" + return CEFR_LABELS.get(cefr_code, cefr_code) + + +class CefrMapper: + """Class-style facade kept for backward compatibility with ``encoach_placement``.""" + + THETA_TO_CEFR = THETA_TO_CEFR + CEFR_TO_BAND = CEFR_TO_BAND + CEFR_LABELS = CEFR_LABELS + + theta_to_cefr = staticmethod(theta_to_cefr) + theta_to_band = staticmethod(theta_to_band) + band_to_cefr = staticmethod(band_to_cefr) + + @staticmethod + def get_cefr_label(cefr_code): + return cefr_label(cefr_code) diff --git a/backend/custom_addons/encoach_ai/services/openai_service.py b/backend/custom_addons/encoach_ai/services/openai_service.py index 622885c7..ac9b9475 100644 --- a/backend/custom_addons/encoach_ai/services/openai_service.py +++ b/backend/custom_addons/encoach_ai/services/openai_service.py @@ -20,16 +20,20 @@ class OpenAIService: self._get_param = env["ir.config_parameter"].sudo().get_param self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes") self.max_retries = int(self._get_param("encoach_ai.max_retries", "3")) + # Hard wall-clock timeout (seconds) enforced by the OpenAI SDK on each + # HTTP call. Prevents a single slow LLM request from tying up an Odoo + # worker for minutes. See P0.5 in docs/PROJECT_SUMMARY.md §21. + self.request_timeout = float(self._get_param("encoach_ai.request_timeout", "30")) api_key = self._get_param("encoach_ai.openai_api_key", "") if not api_key: import os api_key = os.environ.get("OPENAI_API_KEY", "") if _openai_mod and api_key: - self.client = _openai_mod.OpenAI(api_key=api_key) + self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout) else: self.client = None self.model = self._get_param("encoach_ai.openai_model", "gpt-4o") - self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo") + self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini") def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None): try: @@ -86,6 +90,7 @@ class OpenAIService: messages=messages, temperature=temperature, max_tokens=max_tokens, + timeout=self.request_timeout, ) resp = self._retry_with_backoff(_call, action, model) content = resp.choices[0].message.content @@ -112,6 +117,7 @@ class OpenAIService: temperature=temperature, max_tokens=max_tokens, response_format={"type": "json_object"}, + timeout=self.request_timeout, ) resp = self._retry_with_backoff(_call, action, model) raw = resp.choices[0].message.content @@ -341,3 +347,6 @@ class OpenAIService: messages.append({"role": "user", "content": brief_text}) return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096) + + +EncoachOpenAIService = OpenAIService diff --git a/backend/custom_addons/encoach_ai/services/question_validator.py b/backend/custom_addons/encoach_ai/services/question_validator.py new file mode 100644 index 00000000..8a4133a3 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/question_validator.py @@ -0,0 +1,318 @@ +"""Validation + quality gate for AI-generated exam content. + +This module is the single entry point used by exam-generation controllers to: + +1. **Schema-validate** raw LLM output before any DB insert (``validate_payload``), + catching malformed AI responses (missing prompt, empty options for MCQ, + wrong correct_answer format, etc.) before they corrupt the question pool. + +2. **Score content quality** of persisted questions (``score_question``), wrapping + :class:`encoach_quality_gate.services.quality_checker.QualityChecker` and + :class:`encoach_ielts_validation.services.validator.IeltsValidator` with + graceful degradation when those addons are not installed or their optional + Python deps (``textstat``, ``language_tool_python``) are missing. + +Design choices: + +- Zero hard dependency on ``jsonschema`` or ``pydantic`` — the schema is a + lightweight, introspectable dict used by :func:`_validate_dict`. Good enough + for the shapes we actually emit; keeps our external-deps footprint small. +- Quality scoring is best-effort and **never** raises to callers — failures + degrade to a ``warn`` verdict so the HTTP request still completes. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from typing import Any + +_logger = logging.getLogger(__name__) + +VERDICT_OK = "ok" +VERDICT_WARN = "warn" +VERDICT_FAIL = "fail" + +MIN_STEM_LEN = 8 +MIN_QUALITY_SCORE = 0.55 + +_VALID_QUESTION_TYPES = { + "mcq", "mcq_multi", "tfng", "ynng", "gap_fill", "short_answer", + "form_completion", "note_completion", "map_labelling", "matching", + "summary_completion", "heading_matching", "matching_features", + "numerical", "expression", "code_completion", "code_output", + "sql_query", "matrix", "graph", +} + +_SKILL_VALUES = { + "listening", "reading", "writing", "speaking", "grammar", + "vocabulary", "math", "it", +} + + +EXERCISE_SCHEMA: dict[str, dict[str, Any]] = { + "prompt": {"type": str, "required": True, "min_len": MIN_STEM_LEN}, + "type": {"type": str, "required": False, "choices": _VALID_QUESTION_TYPES | { + "multiple_choice", "true_false", "fill_blanks", "matching_headings", + "paragraph_match", "sentence_completion", "matching_information", + }}, + "options": {"type": list, "required": False}, + "correct_answer": {"type": (str, list, int, float), "required": False}, + "marks": {"type": (int, float), "required": False, "min": 0, "max": 20}, + "cefr_level": {"type": str, "required": False}, +} + +MODULE_SCHEMA: dict[str, dict[str, Any]] = { + "difficulty": {"type": (list, str), "required": False}, + "timer": {"type": (int, float), "required": False, "min": 0, "max": 600}, + "totalMarks": {"type": (int, float), "required": False, "min": 0, "max": 500}, + "passages": {"type": list, "required": False}, + "sections": {"type": list, "required": False}, + "tasks": {"type": list, "required": False}, + "parts": {"type": list, "required": False}, +} + + +def _validate_dict(obj: Any, schema: dict, path: str = "") -> list[str]: + """Return list of human-readable validation errors for ``obj`` against ``schema``.""" + errors: list[str] = [] + if not isinstance(obj, dict): + errors.append(f"{path or 'root'}: expected object, got {type(obj).__name__}") + return errors + + for key, rule in schema.items(): + here = f"{path}.{key}" if path else key + if key not in obj or obj[key] in (None, ""): + if rule.get("required"): + errors.append(f"{here}: missing required field") + continue + + val = obj[key] + expected_type = rule.get("type") + if expected_type and not isinstance(val, expected_type): + got = type(val).__name__ + want = ( + expected_type.__name__ if isinstance(expected_type, type) + else "/".join(t.__name__ for t in expected_type) + ) + errors.append(f"{here}: expected {want}, got {got}") + continue + + if isinstance(val, str): + min_len = rule.get("min_len") + if min_len and len(val.strip()) < min_len: + errors.append(f"{here}: too short (< {min_len} chars)") + choices = rule.get("choices") + if choices and val not in choices: + errors.append(f"{here}: '{val}' not in allowed values") + if isinstance(val, (int, float)): + if "min" in rule and val < rule["min"]: + errors.append(f"{here}: {val} < min {rule['min']}") + if "max" in rule and val > rule["max"]: + errors.append(f"{here}: {val} > max {rule['max']}") + return errors + + +def _validate_exercise(ex: Any, path: str = "exercise") -> list[str]: + """Validate a single AI-generated exercise dict.""" + errors = _validate_dict(ex, EXERCISE_SCHEMA, path) + if errors: + return errors + q_type = (ex.get("type") or "mcq").lower() + options = ex.get("options") or [] + correct = ex.get("correct_answer") + if q_type in ("mcq", "multiple_choice"): + if not options or len(options) < 2: + errors.append(f"{path}.options: MCQ requires >= 2 options") + if correct in (None, ""): + errors.append(f"{path}.correct_answer: MCQ requires a correct_answer") + if q_type in ("mcq_multi",): + if not isinstance(correct, list) or len(correct) < 1: + errors.append(f"{path}.correct_answer: mcq_multi requires a list of answers") + if q_type in ("true_false", "tfng", "ynng"): + if isinstance(correct, str) and correct.lower() not in { + "true", "false", "not_given", "yes", "no", "t", "f" + }: + errors.append(f"{path}.correct_answer: '{correct}' not a valid T/F/NG answer") + return errors + + +def validate_payload(payload: Any) -> dict[str, Any]: + """Validate an entire exam-generation submit payload. + + Expected shape (best-effort, since the frontend already normalizes it): + + .. code-block:: text + + { + "title": str (required), + "modules": { + "": { + "passages": [{"text": str, "exercises": [ExerciseSchema, ...]}], + "sections": [...], "tasks": [...], "parts": [...], + ... + }, + ... + } + } + + Returns a dict with:: + + {"verdict": "ok"|"warn"|"fail", "errors": [...], "warnings": [...]} + + ``verdict == "fail"`` means the caller MUST NOT persist this payload as-is. + """ + errors: list[str] = [] + warnings: list[str] = [] + + if not isinstance(payload, dict): + return {"verdict": VERDICT_FAIL, + "errors": [f"payload: expected object, got {type(payload).__name__}"], + "warnings": []} + + title = (payload.get("title") or "").strip() + if not title: + errors.append("title: missing required field") + + modules = payload.get("modules") or {} + if not isinstance(modules, dict) or not modules: + errors.append("modules: missing or empty — nothing to persist") + return {"verdict": VERDICT_FAIL, "errors": errors, "warnings": warnings} + + total_exercises = 0 + for mod_key, mod in modules.items(): + mpath = f"modules.{mod_key}" + if not isinstance(mod, dict): + errors.append(f"{mpath}: expected object") + continue + errors.extend(_validate_dict(mod, MODULE_SCHEMA, mpath)) + for p_idx, passage in enumerate(mod.get("passages") or []): + if not isinstance(passage, dict): + errors.append(f"{mpath}.passages[{p_idx}]: expected object") + continue + for e_idx, ex in enumerate(passage.get("exercises") or []): + total_exercises += 1 + errors.extend(_validate_exercise( + ex, f"{mpath}.passages[{p_idx}].exercises[{e_idx}]")) + for s_idx, sec in enumerate(mod.get("sections") or []): + if not isinstance(sec, dict): + errors.append(f"{mpath}.sections[{s_idx}]: expected object") + continue + for e_idx, ex in enumerate(sec.get("exercises") or []): + total_exercises += 1 + errors.extend(_validate_exercise( + ex, f"{mpath}.sections[{s_idx}].exercises[{e_idx}]")) + + if total_exercises == 0 and not any( + (m.get("tasks") or m.get("parts")) for m in modules.values() if isinstance(m, dict) + ): + warnings.append("no exercises/tasks/parts found — exam will have no questions") + + if errors: + verdict = VERDICT_FAIL + elif warnings: + verdict = VERDICT_WARN + else: + verdict = VERDICT_OK + return {"verdict": verdict, "errors": errors, "warnings": warnings, + "total_exercises": total_exercises} + + +def prompt_hash(prompt: str) -> str: + """SHA-256 hex digest of a rendered prompt, for provenance tracking.""" + return hashlib.sha256((prompt or "").encode("utf-8")).hexdigest() + + +def score_question(stem: str, *, skill: str | None = None, + cefr_level: str | None = None) -> dict[str, Any]: + """Best-effort quality score for a persisted question stem. + + Wraps QualityChecker + IeltsValidator defensively — any import/runtime + failure downgrades the verdict to ``warn`` without raising. Returns:: + + { + "verdict": "ok"|"warn"|"fail", + "score": float in [0, 1], + "readability": {...} | None, + "grammar_issues": int, + "checks_ran": ["readability", "grammar", ...], + "errors": [str, ...], + } + """ + report: dict[str, Any] = { + "verdict": VERDICT_OK, + "score": 1.0, + "readability": None, + "grammar_issues": 0, + "checks_ran": [], + "errors": [], + } + if not stem or len(stem.strip()) < MIN_STEM_LEN: + report["verdict"] = VERDICT_FAIL + report["score"] = 0.0 + report["errors"].append(f"stem shorter than {MIN_STEM_LEN} chars") + return report + + try: + from odoo.addons.encoach_quality_gate.services.quality_checker import QualityChecker + except Exception as exc: + report["verdict"] = VERDICT_WARN + report["errors"].append(f"quality_gate unavailable: {exc}") + return report + + try: + readability = QualityChecker.check_readability(stem) + report["readability"] = readability + report["checks_ran"].append("readability") + if cefr_level: + cefr_match = QualityChecker.check_cefr_alignment(stem, cefr_level.lower()) + report["cefr_alignment"] = cefr_match + report["checks_ran"].append("cefr_alignment") + if not cefr_match.get("aligned", True): + report["score"] -= 0.2 + grammar = QualityChecker.check_grammar(stem) + report["grammar_issues"] = len(grammar.get("issues", [])) if isinstance(grammar, dict) else 0 + report["checks_ran"].append("grammar") + if report["grammar_issues"] > 3: + report["score"] -= 0.2 + except Exception as exc: + _logger.warning("QualityChecker failed for stem (skill=%s): %s", skill, exc) + report["verdict"] = VERDICT_WARN + report["errors"].append(f"quality_checker error: {exc}") + + report["score"] = max(0.0, min(1.0, report["score"])) + if report["verdict"] == VERDICT_OK and report["score"] < MIN_QUALITY_SCORE: + report["verdict"] = VERDICT_WARN + return report + + +def summarize_question_reports(reports: list[dict]) -> dict[str, Any]: + """Reduce a list of per-question reports to an aggregate verdict.""" + if not reports: + return {"verdict": VERDICT_OK, "total": 0, "failed": 0, + "warned": 0, "avg_score": 1.0} + failed = sum(1 for r in reports if r.get("verdict") == VERDICT_FAIL) + warned = sum(1 for r in reports if r.get("verdict") == VERDICT_WARN) + avg = sum(float(r.get("score") or 0) for r in reports) / max(1, len(reports)) + if failed: + verdict = VERDICT_FAIL + elif warned: + verdict = VERDICT_WARN + else: + verdict = VERDICT_OK + return { + "verdict": verdict, + "total": len(reports), + "failed": failed, + "warned": warned, + "avg_score": round(avg, 3), + } + + +def dumps_report(report: dict) -> str: + """Compact JSON dump safe for storing in a Text column.""" + try: + return json.dumps(report, ensure_ascii=False, default=str) + except Exception: + return "{}" diff --git a/backend/custom_addons/encoach_api/__init__.py b/backend/custom_addons/encoach_api/__init__.py index e046e49f..576a4fa7 100644 --- a/backend/custom_addons/encoach_api/__init__.py +++ b/backend/custom_addons/encoach_api/__init__.py @@ -1 +1,3 @@ +from . import models from . import controllers +from . import utils # exposes odoo.addons.encoach_api.utils.cache diff --git a/backend/custom_addons/encoach_api/__manifest__.py b/backend/custom_addons/encoach_api/__manifest__.py index e9a6fd0d..3bbe219a 100644 --- a/backend/custom_addons/encoach_api/__manifest__.py +++ b/backend/custom_addons/encoach_api/__manifest__.py @@ -8,7 +8,10 @@ 'external_dependencies': { 'python': ['PyJWT'], }, - 'data': [], + 'data': [ + 'security/ir.model.access.csv', + 'data/cron.xml', + ], 'installable': True, 'license': 'LGPL-3', } diff --git a/backend/custom_addons/encoach_api/controllers/__init__.py b/backend/custom_addons/encoach_api/controllers/__init__.py index 736bd7e7..9f5f9454 100644 --- a/backend/custom_addons/encoach_api/controllers/__init__.py +++ b/backend/custom_addons/encoach_api/controllers/__init__.py @@ -1,2 +1,5 @@ from . import base from . import auth +from . import health +from . import openapi +from . import gdpr diff --git a/backend/custom_addons/encoach_api/controllers/auth.py b/backend/custom_addons/encoach_api/controllers/auth.py index 4b4b4226..ded44b62 100644 --- a/backend/custom_addons/encoach_api/controllers/auth.py +++ b/backend/custom_addons/encoach_api/controllers/auth.py @@ -1,5 +1,25 @@ +"""Authentication endpoints. + +Split-token design: + +* ``access_token`` — short-lived (1h), stateless, verified via signature only. + Sent on every request as ``Authorization: Bearer ``. +* ``refresh_token`` — long-lived (7d), stateful, backed by + ``encoach.jwt.token``. Never sent on regular API calls; only to + ``/api/auth/refresh``. Rotated on every refresh so a leaked token is + usable at most once. + +The frontend should store ``refresh_token`` in a secure, non-extractable +location (ideally ``httpOnly`` cookie if the deployment proxy allows it, +otherwise ``localStorage`` behind an explicit trust boundary) and call +``/api/auth/refresh`` in the background whenever the access token is within +2 minutes of expiry. +""" + import logging import time +import uuid +from datetime import datetime, timedelta import jwt as pyjwt @@ -13,6 +33,40 @@ from .base import ( _logger = logging.getLogger(__name__) +ACCESS_TTL_SECONDS = 3600 # 1 hour +REFRESH_TTL_SECONDS = 7 * 86400 # 7 days + + +def _issue_tokens(user, *, user_agent=None, remote_ip=None): + """Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple. + + Persisting the refresh token is the caller's job — they get the ``jti`` + back so they can insert exactly one row. + """ + secret = _get_jwt_secret() + now = int(time.time()) + access_token = pyjwt.encode( + { + "user_id": user.id, + "type": "access", + "iat": now, + "exp": now + ACCESS_TTL_SECONDS, + }, + secret, algorithm="HS256", + ) + refresh_jti = uuid.uuid4().hex + refresh_token = pyjwt.encode( + { + "user_id": user.id, + "type": "refresh", + "jti": refresh_jti, + "iat": now, + "exp": now + REFRESH_TTL_SECONDS, + }, + secret, algorithm="HS256", + ) + return access_token, refresh_token, refresh_jti + class EncoachAuthController(http.Controller): @@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller): if not login or not password: return _error_response('login and password are required', 400) - # Odoo 19: session.authenticate(env, credential_dict) credential = { 'type': 'password', 'login': login, @@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller): user = request.env['res.users'].sudo().browse(uid) - # Generate JWT token secret = _get_jwt_secret() if not secret: return _error_response('JWT not configured on server', 500) - token = pyjwt.encode( - {'user_id': user.id, 'exp': int(time.time()) + 86400}, - secret, algorithm='HS256', - ) + # User-Agent / IP are best-effort — some proxies strip them; we + # record whatever we get but never refuse login because they're + # missing. + ua = request.httprequest.headers.get('User-Agent') if request.httprequest else '' + ip = request.httprequest.remote_addr if request.httprequest else '' + + access_token, refresh_token, refresh_jti = _issue_tokens( + user, user_agent=ua, remote_ip=ip, + ) + request.env['encoach.jwt.token'].sudo().create({ + 'jti': refresh_jti, + 'user_id': user.id, + 'issued_at': fields.Datetime.now(), + 'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS), + 'user_agent': (ua or '')[:255], + 'remote_ip': (ip or '')[:64], + }) - # Get permissions permissions = [] if hasattr(user, 'get_all_permissions'): permissions = user.get_all_permissions().mapped('code') - # Update last login user.write({'last_login': fields.Datetime.now()}) return _json_response({ - 'token': token, + 'token': access_token, # legacy name (frontend fallback) + 'access_token': access_token, + 'refresh_token': refresh_token, + 'expires_in': ACCESS_TTL_SECONDS, + 'refresh_expires_in': REFRESH_TTL_SECONDS, + 'token_type': 'Bearer', 'user': self._user_to_dict(user), 'permissions': permissions, }) @@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller): _logger.exception('login failed') return _error_response(str(e), 500) + # ------------------------------------------------------------------ + # POST /api/auth/refresh + # ------------------------------------------------------------------ + @http.route('/api/auth/refresh', type='http', auth='public', + methods=['POST'], csrf=False) + def refresh(self, **kw): + """Rotate a refresh token into a fresh access+refresh pair. + + Consumes (revokes) the presented refresh token and issues new ones. + This is the *only* place a refresh token is ever accepted, so replay + attempts against other endpoints fail at the access-token layer. + """ + try: + body = _get_json_body() + token = body.get('refresh_token') or '' + if not token: + return _error_response('refresh_token is required', 400) + + secret = _get_jwt_secret() + if not secret: + return _error_response('JWT not configured on server', 500) + + try: + claims = pyjwt.decode(token, secret, algorithms=['HS256']) + except pyjwt.ExpiredSignatureError: + return _error_response('refresh_token expired', 401) + except pyjwt.InvalidTokenError: + return _error_response('refresh_token invalid', 401) + + if claims.get('type') != 'refresh': + return _error_response('wrong token type', 401) + + jti = claims.get('jti') + user_id = claims.get('user_id') + if not jti or not user_id: + return _error_response('refresh_token malformed', 401) + + Token = request.env['encoach.jwt.token'].sudo() + row = Token.search([ + ('jti', '=', jti), + ('user_id', '=', user_id), + ('revoked', '=', False), + ], limit=1) + if not row: + # Either already rotated (possible replay) or never existed. + # Revoke every active token for that user as a defensive + # measure — stolen refresh tokens shouldn't buy multiple + # rotations. + Token.search([ + ('user_id', '=', user_id), + ('revoked', '=', False), + ]).write({'revoked': True}) + return _error_response('refresh_token revoked', 401) + + user = request.env['res.users'].sudo().browse(user_id) + if not user.exists() or not user.active: + return _error_response('user disabled', 401) + + ua = request.httprequest.headers.get('User-Agent') if request.httprequest else '' + ip = request.httprequest.remote_addr if request.httprequest else '' + access_token, refresh_token, refresh_jti = _issue_tokens( + user, user_agent=ua, remote_ip=ip, + ) + row.write({'revoked': True, 'last_used_at': fields.Datetime.now()}) + Token.create({ + 'jti': refresh_jti, + 'user_id': user.id, + 'issued_at': fields.Datetime.now(), + 'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS), + 'user_agent': (ua or '')[:255], + 'remote_ip': (ip or '')[:64], + }) + return _json_response({ + 'access_token': access_token, + 'token': access_token, + 'refresh_token': refresh_token, + 'expires_in': ACCESS_TTL_SECONDS, + 'refresh_expires_in': REFRESH_TTL_SECONDS, + 'token_type': 'Bearer', + }) + except Exception as e: + _logger.exception('refresh failed') + return _error_response(str(e), 500) + # ------------------------------------------------------------------ # GET /api/user (returns current authenticated user) # ------------------------------------------------------------------ @@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller): @http.route('/api/logout', type='http', auth='public', methods=['POST'], csrf=False) def logout(self, **kw): - # JWT is stateless — client clears token. Server just returns OK. + """Revoke the presented refresh token (if any) and return OK. + + Access tokens remain stateless; they expire naturally within an hour. + Clients that care about immediate lockout should also delete the + access token from local storage when they call this endpoint. + """ + try: + body = _get_json_body() or {} + token = body.get('refresh_token') or '' + if token: + secret = _get_jwt_secret() + if secret: + try: + claims = pyjwt.decode( + token, secret, algorithms=['HS256'], + options={'verify_exp': False}, + ) + jti = claims.get('jti') + if jti: + request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti) + except pyjwt.InvalidTokenError: + # Invalid tokens can't be revoked but also can't be + # reused, so this is effectively a no-op. + pass + except Exception: + _logger.exception('logout cleanup failed') return _json_response({'ok': True}) # ------------------------------------------------------------------ diff --git a/backend/custom_addons/encoach_api/controllers/base.py b/backend/custom_addons/encoach_api/controllers/base.py index 1e8b940a..49a551d3 100644 --- a/backend/custom_addons/encoach_api/controllers/base.py +++ b/backend/custom_addons/encoach_api/controllers/base.py @@ -2,6 +2,7 @@ import json import functools import logging import time +import uuid import jwt as pyjwt @@ -9,14 +10,75 @@ from odoo.http import request _logger = logging.getLogger(__name__) +REQUEST_ID_HEADER = "X-Request-Id" + + +def _get_or_create_request_id(): + """Return the X-Request-Id for the current request, minting one if absent. + + The value is cached on ``request`` so the same id is reused for every log + line emitted during the request lifecycle (dispatcher, controller, + post-dispatch). Call sites should prefer :func:`current_request_id`. + """ + existing = getattr(request, "_encoach_request_id", None) + if existing: + return existing + header_val = request.httprequest.headers.get(REQUEST_ID_HEADER) + req_id = (header_val or uuid.uuid4().hex)[:64] + try: + request._encoach_request_id = req_id + except Exception: + pass + return req_id + + +def current_request_id(): + """Best-effort accessor for the active request id (None outside a request).""" + try: + return getattr(request, "_encoach_request_id", None) + except Exception: + return None + + +def _log_json(level, event, **fields): + """Emit a single structured JSON log line, enriched with the request id. + + Keeping all non-interactive logs JSON-formatted lets ops scrape them with + Loki / OpenSearch without brittle regexes. Falls back silently if the + logging backend rejects the payload. + """ + try: + payload = { + "ts": round(time.time(), 3), + "event": event, + "rid": current_request_id(), + } + payload.update(fields) + _logger.log(level, json.dumps(payload, default=str, ensure_ascii=False)) + except Exception: + _logger.log(level, "%s %s", event, fields) + _jwt_secret_cache = {"secret": None, "ts": 0} -_JWT_SECRET_TTL = 300 +# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation +# via ir.config_parameter propagates quickly across all workers without +# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21. +_JWT_SECRET_TTL = 30 _user_exists_cache = {} _USER_CACHE_TTL = 60 _USER_CACHE_MAX = 200 +def invalidate_jwt_secret_cache(): + """Invalidate the per-worker JWT secret cache immediately. + + Called by ``encoach.auth.settings`` whenever the admin rotates the secret + so that subsequent requests pick up the new value on the next read. + """ + _jwt_secret_cache["secret"] = None + _jwt_secret_cache["ts"] = 0 + + def _get_jwt_secret(): now = time.time() if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL: @@ -48,6 +110,12 @@ def validate_token(): return None except pyjwt.InvalidTokenError: return None + # Refresh tokens are only accepted by /api/auth/refresh. Presenting one as + # a Bearer on any other endpoint is treated as unauthenticated so a + # leaked refresh token never buys API access on its own. + token_type = payload.get("type") + if token_type and token_type != "access": + return None user_id = payload.get("user_id") if not user_id: return None @@ -70,26 +138,95 @@ def validate_token(): def jwt_required(func): - """Decorator that validates the JWT token and sets request.env user context.""" + """Decorator that validates the JWT token and sets request.env user context. + + Also handles per-request observability plumbing: + + - Assigns (or honours) an ``X-Request-Id`` so every log line, downstream + service call, and response can be correlated back to the same request. + - Emits a structured ``api.request.start`` / ``api.request.end`` log pair + with duration in ms, status code, user id, route, and method — powering + the P2.3 metrics pipeline once it lands without any extra wiring. + - Echoes the request id back to the caller via the ``X-Request-Id`` + response header. + """ @functools.wraps(func) def wrapper(*args, **kwargs): + req_id = _get_or_create_request_id() + route = request.httprequest.path + method = request.httprequest.method + t0 = time.time() + user = validate_token() if not user: - return _error_response("Authentication required", status=401) + _log_json(logging.INFO, "api.request.unauthorized", + route=route, method=method, status=401) + resp = _error_response("Authentication required", status=401) + try: + resp.headers[REQUEST_ID_HEADER] = req_id + except Exception: + pass + return resp + request.update_env(user=user.id) - return func(*args, **kwargs) + _log_json(logging.INFO, "api.request.start", + route=route, method=method, user_id=user.id) + try: + resp = func(*args, **kwargs) + status = getattr(resp, "status_code", 200) + duration_ms = int((time.time() - t0) * 1000) + _log_json(logging.INFO, "api.request.end", + route=route, method=method, user_id=user.id, + status=status, duration_ms=duration_ms) + try: + resp.headers[REQUEST_ID_HEADER] = req_id + except Exception: + pass + _record_metric(route, status, duration_ms) + return resp + except Exception as exc: + duration_ms = int((time.time() - t0) * 1000) + _log_json(logging.ERROR, "api.request.error", + route=route, method=method, user_id=user.id, + duration_ms=duration_ms, + error=type(exc).__name__, message=str(exc)[:500]) + _record_metric(route, 500, duration_ms) + raise return wrapper +def _record_metric(route, status, duration_ms): + """Soft import of the metrics recorder so this module has no hard cycle.""" + try: + from odoo.addons.encoach_api.controllers.openapi import record_request + record_request(route, status, duration_ms) + except Exception: + pass + + def _json_response(data, status=200): - return request.make_json_response(data, status=status) + resp = request.make_json_response(data, status=status) + try: + rid = current_request_id() + if rid: + resp.headers[REQUEST_ID_HEADER] = rid + except Exception: + pass + return resp def _error_response(message, status=400, code=None): body = {"error": message} if code: body["code"] = code - return request.make_json_response(body, status=status) + resp = request.make_json_response(body, status=status) + try: + rid = current_request_id() + if rid: + resp.headers[REQUEST_ID_HEADER] = rid + except Exception: + pass + return resp def _get_json_body(): @@ -99,6 +236,31 @@ def _get_json_body(): return {} +def paginated_envelope(items, *, total=None, page=None, size=None, extra=None): + """Canonical ``{items, total, page, size}`` envelope. + + This is the platform-wide pagination shape — prefer returning the output of + this helper from any new list endpoint. Legacy keys (``data``, ``results``, + and custom aliases such as ``students`` / ``subjects``) may be added via + the ``extra`` dict for transitional backward compatibility with the + frontend services that still read those keys; new code must read ``items``. + """ + items = list(items or []) + envelope = { + "items": items, + "total": int(total if total is not None else len(items)), + } + if page is not None: + envelope["page"] = int(page) + if size is not None: + envelope["size"] = int(size) + if extra: + for k, v in extra.items(): + if k not in envelope: + envelope[k] = v + return envelope + + def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'): """Paginate an Odoo model search or extract params from a kwargs dict. diff --git a/backend/custom_addons/encoach_api/controllers/gdpr.py b/backend/custom_addons/encoach_api/controllers/gdpr.py new file mode 100644 index 00000000..d7a07f83 --- /dev/null +++ b/backend/custom_addons/encoach_api/controllers/gdpr.py @@ -0,0 +1,316 @@ +"""GDPR data-subject rights endpoints. + +Exposes two routes: + +* ``GET /api/gdpr/export`` + Returns a JSON snapshot of the calling user's personal data — profile, + entity membership, exam attempts, answers, AI interactions, feedback, + tickets, and login history. + +* ``POST /api/gdpr/delete`` + Initiates the "right to erasure" flow. Because Odoo enforces FK + constraints on linked business records (exam attempts, tickets, audit + trail), we **anonymise** instead of hard-deleting: the ``res.users`` + row is deactivated, ``res.partner`` PII fields are wiped, and a + tombstone row is written to ``encoach.gdpr.erasure.request`` for + audit. Hard delete of linked business rows (attempts, answers, + feedback) is performed for records where it is legally required and + where no other user depends on them. + +The flow is intentionally verbose so operators can see what happened. A +production deployment may want to queue the erasure to a background job +and require a cooling-off period before executing; we expose a +``confirm`` flag for that. +""" + +from __future__ import annotations + +import json +import logging + +from odoo import fields, http +from odoo.http import request + +from .base import ( + _error_response, + _get_json_body, + _json_response, + jwt_required, +) + +_logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- +def _safe_records(env_name: str, domain, fields_to_read): + """Read records if the model exists, else return [].""" + env = request.env + if env_name not in env: + return [] + try: + return env[env_name].sudo().search(domain).read(fields_to_read) + except Exception as exc: + _logger.warning("gdpr: could not read %s: %s", env_name, exc) + return [] + + +def _sanitize(values): + """Convert non-JSON-serialisable values to strings/nulls.""" + out = {} + for key, value in values.items(): + if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], int): + # Many2one read tuples — keep both id and display name. + out[key] = {"id": value[0], "display_name": value[1]} + elif isinstance(value, bytes): + out[key] = f"<{len(value)} bytes omitted>" + elif hasattr(value, "isoformat"): + out[key] = value.isoformat() + else: + out[key] = value + return out + + +# ---------------------------------------------------------------------- +# Controller +# ---------------------------------------------------------------------- +class EncoachGdprController(http.Controller): + """Implements export + right-to-erasure endpoints.""" + + # ------------------------------------------------------------------ + # GET /api/gdpr/export + # ------------------------------------------------------------------ + @http.route("/api/gdpr/export", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required + def export(self, **kw): + try: + user = request.env.user + partner = user.partner_id + + profile = { + "id": user.id, + "login": user.login, + "name": user.name, + "email": user.email or "", + "lang": user.lang or "", + "tz": user.tz or "", + "create_date": user.create_date.isoformat() if user.create_date else None, + "login_date": user.login_date.isoformat() if user.login_date else None, + } + partner_data = { + "id": partner.id if partner else None, + "name": partner.name if partner else None, + "email": partner.email if partner else None, + "phone": partner.phone if partner else None, + "mobile": partner.mobile if partner else None, + "street": partner.street if partner else None, + "city": partner.city if partner else None, + "country": partner.country_id.name if partner and partner.country_id else None, + } + + # Entity / role memberships + entity_rel = _safe_records( + "encoach.user.entity.rel", + [("user_id", "=", user.id)], + ["id", "entity_id", "role_id", "is_active"], + ) + + # Exam attempts & answers + attempts = _safe_records( + "encoach.student.attempt", + [("user_id", "=", user.id)], + ["id", "exam_id", "status", "score", "started_at", "submitted_at"], + ) + attempt_ids = [a["id"] for a in attempts] + answers = _safe_records( + "encoach.student.answer", + [("attempt_id", "in", attempt_ids)], + ["id", "attempt_id", "question_id", "student_answer", "is_correct", "score"], + ) if attempt_ids else [] + + # AI feedback they left + feedback = _safe_records( + "encoach.ai.feedback", + [("user_id", "=", user.id)], + ["id", "subject_type", "subject_id", "rating", "comment", "status", "create_date"], + ) + + # AI calls they triggered (logs) + ai_logs = _safe_records( + "encoach.ai.log", + [("user_id", "=", user.id)] if "encoach.ai.log" in request.env else [], + ["id", "service", "action", "model_used", "total_tokens", "status", "create_date"], + ) + + # Tickets they filed + tickets = _safe_records( + "encoach.ticket", + [("created_by_id", "=", user.id)], + ["id", "name", "status", "priority", "assigned_to_id", "create_date"], + ) + + # Feedback on their coaching, if any + coaching_sessions = _safe_records( + "encoach.coaching.session", + [("user_id", "=", user.id)], + ["id", "create_date", "summary"], + ) + + payload = { + "profile": profile, + "partner": partner_data, + "entity_memberships": [_sanitize(r) for r in entity_rel], + "exam_attempts": [_sanitize(r) for r in attempts], + "exam_answers": [_sanitize(r) for r in answers], + "ai_feedback": [_sanitize(r) for r in feedback], + "ai_calls": [_sanitize(r) for r in ai_logs], + "tickets": [_sanitize(r) for r in tickets], + "coaching_sessions": [_sanitize(r) for r in coaching_sessions], + "exported_at": fields.Datetime.to_string(fields.Datetime.now()), + "export_format_version": "1.0", + } + return _json_response(payload) + except Exception as e: + _logger.exception("gdpr export failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/gdpr/delete + # ------------------------------------------------------------------ + @http.route("/api/gdpr/delete", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required + def delete(self, **kw): + try: + body = _get_json_body() or {} + if not body.get("confirm"): + return _error_response( + "Set {\"confirm\": true} to initiate erasure. " + "This action cannot be undone.", + 400, + ) + + user = request.env.user + if user.id == 1 or user.has_group("base.group_system"): + return _error_response( + "Admin accounts cannot self-erase via this endpoint. " + "Contact the data protection officer.", + 403, + ) + + login = user.login + email = user.email or "" + archived_id = user.id + summary = { + "anonymised_partner_fields": [], + "deactivated_user": False, + "deleted_feedback_count": 0, + "deleted_coaching_count": 0, + "retained_attempts_count": 0, + } + + with request.env.cr.savepoint(): + # 1. Anonymise the partner PII. + partner = user.partner_id + if partner: + partner.sudo().write({ + "name": f"deleted-user-{archived_id}", + "email": False, + "phone": False, + "mobile": False, + "street": False, + "street2": False, + "city": False, + "zip": False, + "image_1920": False, + "comment": "Erased per GDPR request.", + }) + summary["anonymised_partner_fields"] = [ + "name", "email", "phone", "mobile", + "street", "street2", "city", "zip", + "image_1920", "comment", + ] + + # 2. Hard-delete their feedback (personal reviews/comments). + if "encoach.ai.feedback" in request.env: + feedback = request.env["encoach.ai.feedback"].sudo().search([ + ("user_id", "=", user.id), + ]) + summary["deleted_feedback_count"] = len(feedback) + feedback.unlink() + + # 3. Hard-delete coaching session transcripts (personal). + if "encoach.coaching.session" in request.env: + sessions = request.env["encoach.coaching.session"].sudo().search([ + ("user_id", "=", user.id), + ]) + summary["deleted_coaching_count"] = len(sessions) + sessions.unlink() + + # 4. Retain exam attempts (aggregate analytics rely on them) + # but strip personal free-text fields where they exist. + if "encoach.student.attempt" in request.env: + retained = request.env["encoach.student.attempt"].sudo().search([ + ("user_id", "=", user.id), + ]) + summary["retained_attempts_count"] = len(retained) + # Clear any written responses that contain user PII — keep + # the scores/timestamps for analytics integrity. + if "encoach.student.answer" in request.env: + answers = request.env["encoach.student.answer"].sudo().search([ + ("attempt_id", "in", retained.ids), + ]) + # student_answer can contain free-text PII. + answers.write({"student_answer": False}) + + # 5. Rewrite AI log user_id → None so we can no longer tie + # those rows to the person. + if "encoach.ai.log" in request.env: + ai_logs = request.env["encoach.ai.log"].sudo().search([ + ("user_id", "=", user.id), + ]) + if ai_logs: + try: + ai_logs.write({"user_id": False}) + except Exception: + # user_id may be required; fall back to deletion. + ai_logs.unlink() + + # 6. Deactivate the user (we can't unlink — FKs). + user.sudo().write({ + "active": False, + "login": f"erased-{archived_id}@example.invalid", + }) + summary["deactivated_user"] = True + + # 7. Write tombstone audit row. + request.env["encoach.gdpr.erasure.request"].sudo().create({ + "user_login": login, + "user_email": email, + "user_id_archived": archived_id, + "status": "completed", + "summary": json.dumps(summary), + "completed_at": fields.Datetime.now(), + }) + + # Blow away any cached JWT sessions for this user. + try: + from odoo.addons.encoach_api.utils.jwt_cache import ( + invalidate_user as _invalidate_user, + ) + _invalidate_user(archived_id) + except Exception as exc: + _logger.debug("jwt cache invalidation skipped: %s", exc) + + return _json_response({ + "ok": True, + "summary": summary, + "message": ( + "Your account has been anonymised and deactivated. " + "Some records (e.g. exam attempts) are retained " + "without personal identifiers for statistical purposes." + ), + }) + except Exception as e: + _logger.exception("gdpr delete failed") + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_api/controllers/health.py b/backend/custom_addons/encoach_api/controllers/health.py new file mode 100644 index 00000000..5e008777 --- /dev/null +++ b/backend/custom_addons/encoach_api/controllers/health.py @@ -0,0 +1,65 @@ +"""Platform health probes (P0.4). + +Exposes unauthenticated ``/api/health`` (quick liveness) and ``/api/health/ready`` +(deep readiness: DB + JWT secret + OpenAI key). Designed to be consumed by +uptime monitors, load-balancer health checks and container orchestrators. +""" + +import logging +import time + +from odoo import http +from odoo.http import request +from odoo.release import version as odoo_version + +_logger = logging.getLogger(__name__) + +_STARTED_AT = time.time() + + +class HealthController(http.Controller): + """Platform health probes — safe to expose publicly.""" + + @http.route("/api/health", type="http", auth="none", methods=["GET"], csrf=False) + def health(self, **_kw): + """Liveness probe: the process is up and able to handle HTTP.""" + return request.make_json_response({ + "status": "ok", + "service": "encoach-backend", + "odoo_version": odoo_version, + "uptime_seconds": int(time.time() - _STARTED_AT), + }) + + @http.route("/api/health/ready", type="http", auth="none", methods=["GET"], csrf=False) + def ready(self, **_kw): + """Readiness probe: DB reachable, JWT secret configured, AI key known.""" + checks = {} + ok = True + + try: + request.env.cr.execute("SELECT 1") + checks["database"] = "ok" + except Exception as exc: + ok = False + checks["database"] = f"error: {exc}" + + try: + IrParam = request.env["ir.config_parameter"].sudo() + checks["jwt_secret"] = "ok" if IrParam.get_param("encoach.jwt_secret") else "missing" + if checks["jwt_secret"] == "missing": + ok = False + ai_key = IrParam.get_param("encoach_ai.openai_api_key") + import os + if not ai_key: + ai_key = os.environ.get("OPENAI_API_KEY", "") + checks["openai_key"] = "ok" if ai_key else "missing" + except Exception as exc: + ok = False + checks["config"] = f"error: {exc}" + + status = 200 if ok else 503 + return request.make_json_response({ + "status": "ok" if ok else "degraded", + "checks": checks, + "uptime_seconds": int(time.time() - _STARTED_AT), + }, status=status) diff --git a/backend/custom_addons/encoach_api/controllers/openapi.py b/backend/custom_addons/encoach_api/controllers/openapi.py new file mode 100644 index 00000000..419cf9d3 --- /dev/null +++ b/backend/custom_addons/encoach_api/controllers/openapi.py @@ -0,0 +1,265 @@ +"""OpenAPI 3.0 + Prometheus-ish metrics exporter. + +Both endpoints are **unauthenticated** by design so they can be scraped by +third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing +to solve a JWT exchange first. The OpenAPI spec is generated by introspecting +every class decorated with :func:`odoo.http.route` and therefore stays in sync +with the running server automatically. + +Scope: the generator emits a minimal but valid OpenAPI document — routes, +HTTP methods, path params, tag derived from the route prefix, and a shared +``BearerAuth`` security scheme. Request / response schemas are left open +(``{"type": "object"}``) since we don't have pydantic models yet; a future +iteration can enrich individual routes via ``route_decorated_method.openapi`` +annotations. +""" + +from __future__ import annotations + +import logging +import re +import time +from collections import Counter, defaultdict + +from odoo import http +from odoo.http import Controller, Response, request + +_logger = logging.getLogger(__name__) + +_metrics_lock = None +_metrics: dict = { + "started_at": time.time(), + "routes": Counter(), # key -> hit count + "statuses": Counter(), # status bucket -> hit count + "latencies_ms": defaultdict(list), # route -> [recent ms samples] (cap 128) +} +_LATENCY_SAMPLE_CAP = 128 +_PATH_PARAM_RE = re.compile(r"<(?:(?P\w+):)?(?P\w+)>") + + +def record_request(route: str, status: int, duration_ms: int) -> None: + """Lightweight in-process request counter used by the metrics endpoint. + + Intentionally thread-unsafe for minimum overhead; worst case is a slightly + off counter on concurrent writes which is fine for ops dashboards. Switch + to a proper Prometheus client (``prometheus_client``) in P2.3 when needed. + """ + try: + _metrics["routes"][route] += 1 + bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx" + _metrics["statuses"][bucket] += 1 + samples = _metrics["latencies_ms"][route] + samples.append(int(duration_ms)) + if len(samples) > _LATENCY_SAMPLE_CAP: + del samples[0:len(samples) - _LATENCY_SAMPLE_CAP] + except Exception: + pass + + +def _convert_odoo_path(path: str) -> str: + """Translate Odoo's ```` placeholder syntax to OpenAPI ``{foo}``.""" + def repl(m: re.Match) -> str: + return "{" + m.group("name") + "}" + return _PATH_PARAM_RE.sub(repl, path) + + +def _path_parameters(path: str) -> list[dict]: + params = [] + for m in _PATH_PARAM_RE.finditer(path): + conv = (m.group("conv") or "string").lower() + if conv in ("int", "integer"): + schema = {"type": "integer"} + elif conv in ("float", "number"): + schema = {"type": "number"} + else: + schema = {"type": "string"} + params.append({ + "name": m.group("name"), + "in": "path", + "required": True, + "schema": schema, + }) + return params + + +def _iter_routes(): + """Yield ``(route_str, method_name, tag, class_name, doc)`` for every + ``@http.route`` decorated callable currently registered. + + Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the + dispatcher actually routes to — including extension classes. + """ + seen = set() + try: + routing_map = request.env["ir.http"].routing_map() + except Exception: + _logger.exception("could not fetch routing_map") + return + + try: + rules = list(routing_map.iter_rules()) + except Exception: + _logger.exception("routing_map has no iter_rules") + return + + for rule in rules: + url = rule.rule + if not isinstance(url, str) or not url.startswith("/api/"): + continue + endpoint = rule.endpoint + # ``endpoint`` is a wrapped callable; the real controller method is + # stashed on ``endpoint.func`` by Odoo. + fn = getattr(endpoint, "func", endpoint) + routing = getattr(fn, "routing", {}) or {} + methods = routing.get("methods") or list(rule.methods or {"GET"}) + methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}] + if not methods: + methods = ["GET"] + cls_name = "" + try: + qual = getattr(fn, "__qualname__", fn.__name__) + cls_name = qual.split(".")[0] if "." in qual else "" + except Exception: + cls_name = "" + module = getattr(fn, "__module__", "") or "" + tag_base = module.split(".")[-2] if "." in module else module or "api" + doc = "" + try: + doc = (fn.__doc__ or "").strip() + except Exception: + pass + for m in methods: + key = (url, m.upper(), cls_name, fn.__name__) + if key in seen: + continue + seen.add(key) + yield url, m.upper(), tag_base, cls_name, doc + + +def _build_openapi_spec() -> dict: + base_url = ( + request.env["ir.config_parameter"].sudo().get_param("web.base.url") + or "http://localhost:8069" + ) + paths: dict[str, dict] = {} + tags: dict[str, str] = {} + + for route, method, tag_base, cls_name, doc in _iter_routes(): + openapi_path = _convert_odoo_path(route) + path_entry = paths.setdefault(openapi_path, {}) + + # Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``). + segs = [s for s in route.split("/") if s and s != "api"] + tag = segs[0] if segs else tag_base + tags.setdefault(tag, f"Endpoints under /api/{tag}") + + summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}" + + op: dict = { + "summary": summary, + "operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}", + "tags": [tag], + "security": [{"BearerAuth": []}], + "responses": { + "200": {"description": "OK", + "content": {"application/json": + {"schema": {"type": "object"}}}}, + "401": {"description": "Authentication required"}, + "400": {"description": "Bad request"}, + "500": {"description": "Server error"}, + }, + } + params = _path_parameters(route) + if params: + op["parameters"] = params + if method in ("POST", "PUT", "PATCH"): + op["requestBody"] = { + "required": False, + "content": {"application/json": + {"schema": {"type": "object"}}}, + } + path_entry[method.lower()] = op + + return { + "openapi": "3.0.3", + "info": { + "title": "EnCoach Platform API", + "version": "19.0.1.0", + "description": ( + "Auto-generated from ``@http.route`` decorators. " + "Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the " + "roadmap to enrich request/response shapes." + ), + }, + "servers": [{"url": base_url}], + "tags": [{"name": name, "description": desc} + for name, desc in sorted(tags.items())], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", "scheme": "bearer", "bearerFormat": "JWT", + }, + }, + }, + "paths": dict(sorted(paths.items())), + } + + +class EncoachOpenAPIController(Controller): + + @http.route("/api/openapi.json", type="http", auth="none", + methods=["GET"], csrf=False) + def openapi(self, **_kw): + """Return a freshly generated OpenAPI 3.0 spec for the running server.""" + try: + spec = _build_openapi_spec() + return request.make_json_response(spec) + except Exception: + _logger.exception("openapi generation failed") + return request.make_json_response( + {"error": "could not generate openapi spec"}, status=500, + ) + + @http.route("/api/metrics", type="http", auth="none", + methods=["GET"], csrf=False) + def metrics(self, **_kw): + """Return in-process request counters in a Prometheus-compatible text format. + + This is a minimalist shim, not a full Prometheus client. It exposes: + + - ``encoach_requests_total{route, method, status_bucket}`` — request count + - ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based) + - ``encoach_uptime_seconds`` — process uptime + """ + lines = [ + "# HELP encoach_uptime_seconds Seconds since this worker booted", + "# TYPE encoach_uptime_seconds gauge", + f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}", + "", + "# HELP encoach_requests_total Total number of /api/* requests handled", + "# TYPE encoach_requests_total counter", + ] + for route, count in _metrics["routes"].most_common(): + safe = route.replace('"', '\\"') + lines.append(f'encoach_requests_total{{route="{safe}"}} {count}') + lines.append("") + lines.append("# HELP encoach_responses_total Count of responses per status bucket") + lines.append("# TYPE encoach_responses_total counter") + for bucket, count in _metrics["statuses"].items(): + lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}') + lines.append("") + lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route") + lines.append("# TYPE encoach_request_latency_ms_p95 gauge") + for route, samples in _metrics["latencies_ms"].items(): + if not samples: + continue + sorted_samples = sorted(samples) + p95 = sorted_samples[min(len(sorted_samples) - 1, + int(0.95 * len(sorted_samples)))] + safe = route.replace('"', '\\"') + lines.append( + f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}' + ) + body = "\n".join(lines) + "\n" + return Response(body, status=200, + content_type="text/plain; version=0.0.4") diff --git a/backend/custom_addons/encoach_api/data/cron.xml b/backend/custom_addons/encoach_api/data/cron.xml new file mode 100644 index 00000000..e35a8be1 --- /dev/null +++ b/backend/custom_addons/encoach_api/data/cron.xml @@ -0,0 +1,12 @@ + + + + EnCoach: purge expired JWT refresh tokens + + code + model.purge_expired() + 1 + days + + + diff --git a/backend/custom_addons/encoach_api/models/__init__.py b/backend/custom_addons/encoach_api/models/__init__.py new file mode 100644 index 00000000..96b0f48e --- /dev/null +++ b/backend/custom_addons/encoach_api/models/__init__.py @@ -0,0 +1,2 @@ +from . import jwt_token +from . import gdpr_erasure diff --git a/backend/custom_addons/encoach_api/models/gdpr_erasure.py b/backend/custom_addons/encoach_api/models/gdpr_erasure.py new file mode 100644 index 00000000..0e5451ee --- /dev/null +++ b/backend/custom_addons/encoach_api/models/gdpr_erasure.py @@ -0,0 +1,35 @@ +"""Tombstone record written when a user exercises the right-to-erasure. + +We cannot hard-delete the original res.users row (ORM FK constraints on +attempts, tickets, audit trail, etc.), so this table lets us prove the +request happened, what was anonymised/deleted, and when. +""" + +from odoo import fields, models + + +class EncoachGdprErasureRequest(models.Model): + _name = "encoach.gdpr.erasure.request" + _description = "GDPR right-to-erasure audit trail" + _order = "create_date desc" + + user_login = fields.Char(required=True, index=True) + user_email = fields.Char(index=True) + user_id_archived = fields.Integer( + index=True, + help="Id of the res.users row at time of erasure (row is now archived).", + ) + status = fields.Selection( + [ + ("requested", "Requested"), + ("completed", "Completed"), + ("partial", "Partial — manual follow-up required"), + ], + default="requested", required=True, + ) + summary = fields.Text( + help="JSON summary of what was deleted/anonymised/retained.", + ) + requested_at = fields.Datetime(default=fields.Datetime.now) + completed_at = fields.Datetime() + notes = fields.Text() diff --git a/backend/custom_addons/encoach_api/models/jwt_token.py b/backend/custom_addons/encoach_api/models/jwt_token.py new file mode 100644 index 00000000..2a607a99 --- /dev/null +++ b/backend/custom_addons/encoach_api/models/jwt_token.py @@ -0,0 +1,105 @@ +"""Refresh-token ledger for the EnCoach API. + +Access tokens stay short-lived (1 hour) and stateless — they are verified +with the JWT signature alone. Refresh tokens, on the other hand, must be +revocable so compromised devices can be signed out without rotating the +server secret. Every refresh token corresponds to one row here, keyed by a +UUID (``jti``) that the client echoes back on ``/api/auth/refresh``. + +Rotation policy: ``/api/auth/refresh`` consumes the presented row (revokes +it) and issues a brand-new row, so a stolen refresh token only works once. +Replay attempts after rotation will be rejected because the replayed ``jti`` +is no longer ``active``. +""" + +from __future__ import annotations + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class EncoachJwtToken(models.Model): + _name = "encoach.jwt.token" + _description = "JWT Refresh Token Ledger" + _order = "issued_at desc" + _rec_name = "jti" + + jti = fields.Char( + string="Token ID", + required=True, + index=True, + help="UUID used as the JWT's `jti` claim; serves as the primary handle.", + ) + user_id = fields.Many2one( + "res.users", + string="User", + required=True, + ondelete="cascade", + index=True, + ) + issued_at = fields.Datetime(required=True, default=fields.Datetime.now) + expires_at = fields.Datetime(required=True) + last_used_at = fields.Datetime() + revoked = fields.Boolean(default=False, index=True) + user_agent = fields.Char(help="User-Agent header of the issuing client, for audit.") + remote_ip = fields.Char(help="Remote IP of the issuing client, for audit.") + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + "encoach_jwt_token_jti_unique", + "CREATE UNIQUE INDEX IF NOT EXISTS encoach_jwt_token_jti_unique " + "ON encoach_jwt_token (jti)", + ), + ( + "encoach_jwt_token_user_revoked_idx", + "CREATE INDEX IF NOT EXISTS encoach_jwt_token_user_revoked_idx " + "ON encoach_jwt_token (user_id, revoked, expires_at)", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res + + @api.model + def revoke_by_jti(self, jti: str) -> bool: + """Soft-revoke every row matching ``jti``. + + Returns ``True`` iff at least one row was flipped. Called from + ``/api/auth/refresh`` (to consume the rotated token) and + ``/api/logout`` (to end the session). + """ + if not jti: + return False + rows = self.sudo().search([("jti", "=", jti), ("revoked", "=", False)]) + if not rows: + return False + rows.write({"revoked": True}) + return True + + @api.model + def purge_expired(self, batch_size: int = 500) -> int: + """Remove rows that expired more than a day ago. + + Wired to the daily cleanup cron in :file:`data/cron.xml`; manual + invocations (e.g. migrations) should pass a larger ``batch_size`` + to avoid multi-pass overhead. + """ + from datetime import datetime, timedelta + cutoff = datetime.utcnow() - timedelta(days=1) + rows = self.sudo().search( + [("expires_at", "<", cutoff)], + limit=batch_size, + ) + n = len(rows) + if n: + rows.unlink() + return n diff --git a/backend/custom_addons/encoach_api/security/ir.model.access.csv b/backend/custom_addons/encoach_api/security/ir.model.access.csv new file mode 100644 index 00000000..72457671 --- /dev/null +++ b/backend/custom_addons/encoach_api/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_jwt_token_user,encoach.jwt.token user,model_encoach_jwt_token,base.group_user,1,0,0,0 +access_encoach_jwt_token_admin,encoach.jwt.token admin,model_encoach_jwt_token,base.group_system,1,1,1,1 +access_gdpr_erasure_admin,encoach.gdpr.erasure.request admin,model_encoach_gdpr_erasure_request,base.group_system,1,1,1,1 diff --git a/backend/custom_addons/encoach_api/tests/__init__.py b/backend/custom_addons/encoach_api/tests/__init__.py new file mode 100644 index 00000000..f0f7ac14 --- /dev/null +++ b/backend/custom_addons/encoach_api/tests/__init__.py @@ -0,0 +1 @@ +from . import test_health diff --git a/backend/custom_addons/encoach_api/tests/test_health.py b/backend/custom_addons/encoach_api/tests/test_health.py new file mode 100644 index 00000000..e89f4310 --- /dev/null +++ b/backend/custom_addons/encoach_api/tests/test_health.py @@ -0,0 +1,27 @@ +"""Smoke tests for the health + GDPR endpoints. + +Run with: + ./odoo-bin -c odoo.conf --test-tags encoach_api --stop-after-init +""" + +import json + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install", "encoach_api") +class TestHealthEndpoints(HttpCase): + def test_health_live_returns_200(self): + """``GET /api/health`` must always return 200 once Odoo has booted.""" + response = self.url_open("/api/health", timeout=10) + self.assertEqual(response.status_code, 200) + body = response.json() if hasattr(response, "json") else json.loads(response.text) + self.assertTrue(body.get("ok") is True or body.get("status") in {"ok", "live"}) + + def test_health_ready_returns_2xx(self): + """``GET /api/health/ready`` is allowed to flap, but must not 5xx.""" + response = self.url_open("/api/health/ready", timeout=10) + self.assertIn( + response.status_code, (200, 503), + f"unexpected status {response.status_code}: {response.text[:200]}", + ) diff --git a/backend/custom_addons/encoach_api/utils/__init__.py b/backend/custom_addons/encoach_api/utils/__init__.py new file mode 100644 index 00000000..ea4d3909 --- /dev/null +++ b/backend/custom_addons/encoach_api/utils/__init__.py @@ -0,0 +1 @@ +from . import cache # noqa: F401 diff --git a/backend/custom_addons/encoach_api/utils/cache.py b/backend/custom_addons/encoach_api/utils/cache.py new file mode 100644 index 00000000..2336ed33 --- /dev/null +++ b/backend/custom_addons/encoach_api/utils/cache.py @@ -0,0 +1,106 @@ +"""Zero-dependency in-process TTL cache for hot endpoints. + +Designed for read-heavy, mostly-idempotent endpoints like the admin report +pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``) +and the AI narrative generator. A single LRU-ish dict per worker with a hard +time bound is usually enough to absorb the dashboard refresh storm caused by +an admin tabbing between pages. + +**Not** a substitute for Redis — cross-worker consistency and bounded memory +are out of scope. If ops ever needs those guarantees, swap the internal dict +for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and +``invalidate``) stays identical. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import threading +import time +from functools import wraps +from typing import Any, Callable + +_logger = logging.getLogger(__name__) + +_cache: dict[str, tuple[float, Any]] = {} +_lock = threading.Lock() +_MAX_ENTRIES = 512 + + +def _make_key(namespace: str, args: tuple, kwargs: dict) -> str: + payload = json.dumps([args, kwargs], sort_keys=True, default=str) + digest = hashlib.md5(payload.encode("utf-8")).hexdigest() + return f"{namespace}:{digest}" + + +def get(key: str): + entry = _cache.get(key) + if not entry: + return None + expiry, value = entry + if expiry < time.time(): + with _lock: + _cache.pop(key, None) + return None + return value + + +def put(key: str, value, ttl_seconds: int) -> None: + with _lock: + if len(_cache) >= _MAX_ENTRIES: + oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4] + for k, _v in oldest: + _cache.pop(k, None) + _cache[key] = (time.time() + max(1, ttl_seconds), value) + + +def invalidate(namespace: str | None = None) -> int: + """Drop every cached entry, or just entries under ``namespace``. + + Returns the number of removed entries. Callers should invoke this from + write endpoints that affect the cached read so subsequent reads observe + the new state. + """ + removed = 0 + with _lock: + if namespace is None: + removed = len(_cache) + _cache.clear() + return removed + prefix = f"{namespace}:" + keys = [k for k in _cache if k.startswith(prefix)] + for k in keys: + _cache.pop(k, None) + removed = len(keys) + return removed + + +def memoize_ttl(namespace: str, ttl_seconds: int = 30): + """Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key. + + The key is derived from the namespace + a stable JSON dump of the args, + so callers don't need to worry about mutable keyword order or unhashable + defaults. JWT decorator should run *before* this one so unauthenticated + traffic never hits the cache. + """ + def decorator(func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + try: + key = _make_key(namespace, args, kwargs) + except Exception: + return func(*args, **kwargs) + cached = get(key) + if cached is not None: + return cached + value = func(*args, **kwargs) + try: + put(key, value, ttl_seconds) + except Exception: + _logger.debug("cache put failed for %s", namespace, exc_info=True) + return value + wrapper._encoach_cache_namespace = namespace + return wrapper + return decorator diff --git a/backend/custom_addons/encoach_core/data/encoach_permissions.xml b/backend/custom_addons/encoach_core/data/encoach_permissions.xml index 3986610a..7314737a 100644 --- a/backend/custom_addons/encoach_core/data/encoach_permissions.xml +++ b/backend/custom_addons/encoach_core/data/encoach_permissions.xml @@ -1,6 +1,6 @@ - + viewCorporate diff --git a/backend/custom_addons/encoach_core/models/permission.py b/backend/custom_addons/encoach_core/models/permission.py index a6d6be70..54245e1a 100644 --- a/backend/custom_addons/encoach_core/models/permission.py +++ b/backend/custom_addons/encoach_core/models/permission.py @@ -6,7 +6,9 @@ class EncoachPermission(models.Model): _description = "EnCoach Permission" code = fields.Char(required=True, index=True) - topic = fields.Char() + name = fields.Char() + description = fields.Text() + topic = fields.Char(string="Category") legacy_id = fields.Char(index=True) _code_unique = models.Constraint( diff --git a/backend/custom_addons/encoach_core/models/role.py b/backend/custom_addons/encoach_core/models/role.py index 98c7e490..7e460303 100644 --- a/backend/custom_addons/encoach_core/models/role.py +++ b/backend/custom_addons/encoach_core/models/role.py @@ -1,4 +1,4 @@ -from odoo import models, fields +from odoo import models, fields, api class EncoachRole(models.Model): @@ -6,10 +6,22 @@ class EncoachRole(models.Model): _description = "EnCoach Role" name = fields.Char(required=True) + code = fields.Char(index=True) + description = fields.Text() entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") permission_ids = fields.Many2many("encoach.permission", string="Permissions") + user_ids = fields.Many2many( + "res.users", "encoach_role_user_rel", "role_id", "user_id", + string="Users", + ) is_default = fields.Boolean(default=False) legacy_id = fields.Char(index=True) + user_count = fields.Integer(compute="_compute_user_count", store=True) + + @api.depends("user_ids") + def _compute_user_count(self): + for rec in self: + rec.user_count = len(rec.user_ids) def to_encoach_dict(self): self.ensure_one() diff --git a/backend/custom_addons/encoach_exam_template/__manifest__.py b/backend/custom_addons/encoach_exam_template/__manifest__.py index c57cf063..8d9dbeb9 100644 --- a/backend/custom_addons/encoach_exam_template/__manifest__.py +++ b/backend/custom_addons/encoach_exam_template/__manifest__.py @@ -11,6 +11,7 @@ 'data/ielts_templates.xml', 'data/sample_passages.xml', 'data/sample_questions.xml', + 'data/ir_cron_schedule.xml', 'views/exam_template_views.xml', 'views/passage_views.xml', 'views/audio_file_views.xml', diff --git a/backend/custom_addons/encoach_exam_template/controllers/__init__.py b/backend/custom_addons/encoach_exam_template/controllers/__init__.py index a1995d3b..b7bf0a46 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/__init__.py +++ b/backend/custom_addons/encoach_exam_template/controllers/__init__.py @@ -3,4 +3,8 @@ from . import ielts_exam from . import custom_exam from . import exam_structures from . import assignments +from . import exam_schedules from . import rubrics +from . import approval_workflows +from . import entities +from . import review_workflow diff --git a/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py b/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py new file mode 100644 index 00000000..22237ef7 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py @@ -0,0 +1,358 @@ +"""Approval Workflow API. + +Replaces the earlier raw-SQL implementation with a standard Odoo ORM +controller. Backing models live in ``encoach_exam_template/models/approval.py``. + +Serves the admin "Approval Config" page (``/admin/approval-config``). +""" +import logging +from datetime import datetime + +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + + +# ── Serializers ───────────────────────────────────────────────────────────── + +def _ser_stage(stage): + """Stages are written as ``sequence`` (ORM) but the admin UI speaks + ``order`` (1-based, dense). We emit both so either side works.""" + return { + 'id': stage.id, + 'order': stage.sequence, + 'sequence': stage.sequence, + 'approver_id': stage.approver_id.id or None, + 'approver_name': stage.approver_id.name if stage.approver_id else '', + 'max_days': stage.max_days or 0, + 'auto_escalate': bool(stage.auto_escalate), + 'notification_email': stage.notification_email or '', + 'status': stage.status or 'pending', + 'comment': stage.comment or '', + 'acted_at': stage.acted_at.isoformat() if stage.acted_at else None, + } + + +def _ser_workflow(wf): + return { + 'id': wf.id, + 'name': wf.name or '', + 'type': wf.type or 'custom', + 'status': wf.status or 'draft', + 'allow_bypass': bool(wf.allow_bypass), + 'entity_id': wf.entity_id.id or None, + 'entity_name': wf.entity_id.name if wf.entity_id else None, + 'steps': [_ser_stage(s) for s in wf.stage_ids.sorted('sequence')], + 'stage_count': wf.stage_count, + 'request_count': wf.request_count, + 'created_at': wf.create_date.isoformat() if wf.create_date else None, + # Legacy shape kept for any older callers that read `created`. + 'created': wf.create_date.strftime('%Y-%m-%d') if wf.create_date else '', + } + + +def _ser_request(req): + stage = req.current_stage_id + return { + 'id': req.id, + 'workflow_id': req.workflow_id.id or None, + 'workflow_name': req.workflow_id.name if req.workflow_id else '', + 'res_model': req.res_model or '', + 'res_id': req.res_id or 0, + 'state': req.state or 'draft', + 'requester_id': req.requester_id.id or None, + 'requester_name': req.requester_id.name if req.requester_id else '', + 'current_stage_id': stage.id or None, + 'current_stage_sequence': stage.sequence if stage else None, + 'bypass_reason': req.bypass_reason or '', + 'created_at': req.created_at.isoformat() if req.created_at else None, + } + + +# ── Write helpers ─────────────────────────────────────────────────────────── + +def _apply_workflow_writes(body, vals): + for k in ('name', 'type'): + if k in body and body[k] is not None: + vals[k] = body[k] + if 'status' in body and body['status'] in ('draft', 'active', 'archived'): + vals['status'] = body['status'] + if 'allow_bypass' in body: + vals['allow_bypass'] = bool(body['allow_bypass']) + # Frontend sends `bypass_enabled` from the workflow-builder dialog. + if 'bypass_enabled' in body: + vals['allow_bypass'] = bool(body['bypass_enabled']) + if 'entity_id' in body: + vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False + return vals + + +def _stage_vals_from_body(step, default_seq): + """Accept either `order` (UI) or `sequence` (ORM). Emit `sequence` only.""" + seq = step.get('sequence') + if seq is None: + seq = step.get('order', default_seq) + try: + seq = int(seq) + except (TypeError, ValueError): + seq = default_seq + approver_id = step.get('approver_id') + try: + approver_id = int(approver_id) if approver_id else 0 + except (TypeError, ValueError): + approver_id = 0 + vals = { + 'sequence': seq, + 'approver_id': approver_id or False, + 'max_days': int(step.get('max_days') or 3), + 'auto_escalate': bool(step.get('auto_escalate')), + 'notification_email': step.get('notification_email') or False, + } + return vals + + +# ── Controller ────────────────────────────────────────────────────────────── + +class ApprovalWorkflowController(http.Controller): + + # ── Workflows ──────────────────────────────────────────────── + + @http.route('/api/approval-workflows', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def list_workflows(self, **kw): + try: + M = request.env['encoach.approval.workflow'].sudo() + domain = [] + if kw.get('status'): + domain.append(('status', '=', kw['status'])) + if kw.get('entity_id'): + domain.append(('entity_id', '=', int(kw['entity_id']))) + recs = M.search(domain, order='create_date desc') + items = [_ser_workflow(r) for r in recs] + # Envelope supports both {items,total} (legacy) and {results,total} + # (PaginatedResponse shape used by the approvals frontend service). + return _json_response({ + 'items': items, + 'results': items, + 'total': len(items), + }) + except Exception as e: + _logger.exception('list approval workflows failed') + return _error_response(str(e), 500) + + @http.route('/api/approval-workflows', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def create_workflow(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('name is required', 400) + + vals = {'name': name, 'type': body.get('type', 'custom')} + _apply_workflow_writes(body, vals) + + steps = body.get('steps') or [] + stage_cmds = [] + for i, step in enumerate(steps): + sv = _stage_vals_from_body(step, (i + 1) * 10) + if sv.get('approver_id'): + stage_cmds.append((0, 0, sv)) + if stage_cmds: + vals['stage_ids'] = stage_cmds + + rec = request.env['encoach.approval.workflow'].sudo().create(vals) + return _json_response(_ser_workflow(rec), 201) + except Exception as e: + _logger.exception('create approval workflow failed') + return _error_response(str(e), 500) + + @http.route('/api/approval-workflows/', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def get_workflow(self, wf_id, **kw): + try: + rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_workflow(rec)) + except Exception as e: + _logger.exception('get approval workflow failed') + return _error_response(str(e), 500) + + @http.route('/api/approval-workflows/', type='http', + auth='none', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_workflow(self, wf_id, **kw): + try: + rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + _apply_workflow_writes(body, vals) + + # Replace stages only when steps key is present in the body so + # a status-only toggle doesn't wipe stages. + if 'steps' in body and isinstance(body['steps'], list): + cmds = [(5, 0, 0)] + for i, step in enumerate(body['steps']): + sv = _stage_vals_from_body(step, (i + 1) * 10) + if sv.get('approver_id'): + cmds.append((0, 0, sv)) + vals['stage_ids'] = cmds + + if vals: + rec.write(vals) + return _json_response(_ser_workflow(rec)) + except Exception as e: + _logger.exception('update approval workflow failed') + return _error_response(str(e), 500) + + @http.route('/api/approval-workflows/', type='http', + auth='none', methods=['DELETE'], csrf=False) + @jwt_required + def delete_workflow(self, wf_id, **kw): + try: + rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete approval workflow failed') + return _error_response(str(e), 500) + + # ── Approval Requests ──────────────────────────────────────── + + @http.route('/api/approval-requests', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def list_requests(self, **kw): + try: + M = request.env['encoach.approval.request'].sudo() + domain = [] + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + if kw.get('workflow_id'): + domain.append(('workflow_id', '=', int(kw['workflow_id']))) + recs = M.search(domain, order='created_at desc, id desc') + items = [_ser_request(r) for r in recs] + return _json_response({ + 'items': items, + 'results': items, + 'total': len(items), + }) + except Exception as e: + _logger.exception('list approval requests failed') + return _error_response(str(e), 500) + + @http.route('/api/approval-requests//approve', type='http', + auth='none', methods=['POST'], csrf=False) + @jwt_required + def approve_request(self, req_id, **kw): + """Approve the current stage or finalize the request. + + Wrapped in a ``SAVEPOINT`` so stage + request writes commit or roll back + atomically. The savepoint is released only after both writes succeed, + preventing partial states (e.g. stage marked ``approved`` while request + still ``in_progress``) that were possible with the previous + non-transactional implementation. + """ + try: + body = _get_json_body() + req_rec = request.env['encoach.approval.request'].sudo().browse(req_id) + if not req_rec.exists(): + return _error_response('Request not found', 404) + + with request.env.cr.savepoint(): + stage = req_rec.current_stage_id + if stage: + stage.write({ + 'status': 'approved', + 'comment': body.get('comment', ''), + 'acted_at': datetime.now(), + }) + + stages = req_rec.workflow_id.stage_ids.sorted('sequence') + stage_ids = [s.id for s in stages] + try: + idx = stage_ids.index(stage.id) if stage else -1 + except ValueError: + idx = -1 + + if 0 <= idx and idx + 1 < len(stage_ids): + next_stage = request.env['encoach.approval.stage'].sudo().browse( + stage_ids[idx + 1] + ) + req_rec.write({ + 'current_stage_id': next_stage.id, + 'state': 'in_progress', + }) + else: + req_rec.write({'state': 'approved'}) + + return _json_response({'success': True, 'id': req_id, + 'state': req_rec.state}) + except Exception as e: + _logger.exception('approve request failed') + return _error_response(str(e), 500) + + @http.route('/api/approval-requests//reject', type='http', + auth='none', methods=['POST'], csrf=False) + @jwt_required + def reject_request(self, req_id, **kw): + """Reject a stage and mark the request rejected atomically. + + Uses a ``SAVEPOINT`` so that if the request ``write`` fails after the + stage has been updated, Postgres rolls both writes back and the caller + sees a clean 500 rather than a half-updated workflow. + """ + try: + body = _get_json_body() + req_rec = request.env['encoach.approval.request'].sudo().browse(req_id) + if not req_rec.exists(): + return _error_response('Request not found', 404) + with request.env.cr.savepoint(): + stage = req_rec.current_stage_id + if stage: + stage.write({ + 'status': 'rejected', + 'comment': body.get('comment', ''), + 'acted_at': datetime.now(), + }) + req_rec.write({'state': 'rejected'}) + return _json_response({'success': True, 'id': req_id, + 'state': req_rec.state}) + except Exception as e: + _logger.exception('reject request failed') + return _error_response(str(e), 500) + + # ── Approvers picker ───────────────────────────────────────── + + @http.route('/api/approval-users', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def list_users(self, **kw): + try: + Users = request.env['res.users'].sudo() + domain = [('active', '=', True), ('id', '>', 1)] + search = (kw.get('search') or '').strip() + if search: + domain += ['|', ('name', 'ilike', search), ('login', 'ilike', search)] + recs = Users.search(domain, order='name', limit=100) + items = [{ + 'id': u.id, + 'name': u.name or u.login, + 'login': u.login or '', + 'email': u.email or '', + } for u in recs] + return _json_response({'items': items, 'results': items, 'total': len(items)}) + except Exception as e: + _logger.exception('list approval users failed') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_exam_template/controllers/entities.py b/backend/custom_addons/encoach_exam_template/controllers/entities.py new file mode 100644 index 00000000..6ce01d3b --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/entities.py @@ -0,0 +1,175 @@ +import logging + +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + +ENTITY_MODEL = 'encoach.entity' + + +def _entity_to_dict(entity): + d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else { + 'id': entity.id, + 'name': entity.name, + 'code': getattr(entity, 'code', '') or '', + 'type': getattr(entity, 'type', '') or '', + 'active': entity.active if hasattr(entity, 'active') else True, + 'user_count': getattr(entity, 'user_count', 0) or 0, + } + d['role_count'] = len(entity.role_ids) if hasattr(entity, 'role_ids') else 0 + return d + + +def _role_to_dict(role): + return { + 'id': role.id, + 'name': role.name, + 'code': getattr(role, 'code', '') or '', + 'entity_id': role.entity_id.id if hasattr(role, 'entity_id') and role.entity_id else None, + 'permission_ids': role.permission_ids.ids if hasattr(role, 'permission_ids') else [], + 'user_count': len(role.user_ids) if hasattr(role, 'user_ids') else 0, + } + + +class EntityController(http.Controller): + + @http.route('/api/entities', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_entities(self, **kw): + try: + M = request.env[ENTITY_MODEL].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('type'): + domain.append(('type', '=', kw['type'])) + recs = M.search(domain, order='name') + items = [_entity_to_dict(r) for r in recs] + return _json_response({ + 'data': items, + 'items': items, + 'total': len(items), + }) + except Exception as e: + _logger.exception('list entities failed') + return _error_response(str(e), 500) + + @http.route('/api/entities/', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def get_entity(self, entity_id, **kw): + try: + entity = request.env[ENTITY_MODEL].sudo().browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + return _json_response({'data': _entity_to_dict(entity)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/entities', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_entity(self, **kw): + body = _get_json_body() + name = body.get('name', '').strip() + if not name: + return _error_response('Name is required', 400) + + code = body.get('code', '').strip() + if not code: + code = name.upper().replace(' ', '_')[:20] + + vals = {'name': name, 'code': code} + for field in ('type', 'primary_color', 'secondary_color', + 'background_color', 'login_title', + 'login_description', 'results_release_mode'): + if field in body: + vals[field] = body[field] + + try: + entity = request.env[ENTITY_MODEL].sudo().create(vals) + return _json_response({'data': _entity_to_dict(entity)}, 201) + except Exception as e: + _logger.exception('Error creating entity') + msg = str(e) + if 'unique' in msg.lower() or 'duplicate' in msg.lower(): + return _error_response( + f'An entity with code "{code}" already exists', 409) + return _error_response(msg, 500) + + @http.route('/api/entities/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_entity(self, entity_id, **kw): + body = _get_json_body() + try: + entity = request.env[ENTITY_MODEL].sudo().browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + + vals = {} + for field in ('name', 'code', 'type', 'primary_color', + 'secondary_color', 'background_color', + 'login_title', 'login_description', + 'results_release_mode', 'active'): + if field in body: + vals[field] = body[field] + if vals: + entity.write(vals) + return _json_response({'data': _entity_to_dict(entity)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/entities/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_entity(self, entity_id, **kw): + try: + entity = request.env[ENTITY_MODEL].sudo().browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + entity.sudo().unlink() + return _json_response({'success': True}) + except Exception as e: + msg = str(e) + if 'foreign key' in msg.lower() or 'restrict' in msg.lower(): + return _error_response( + 'Cannot delete: entity has linked users or roles', 409) + return _error_response(msg, 500) + + @http.route('/api/entities//roles', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_entity_roles(self, entity_id, **kw): + try: + roles = request.env['encoach.role'].sudo().search([ + ('entity_id', '=', entity_id) + ]) + return _json_response({ + 'data': [_role_to_dict(r) for r in roles], + 'total': len(roles), + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/entities//roles', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_entity_role(self, entity_id, **kw): + body = _get_json_body() + name = body.get('name', '').strip() + if not name: + return _error_response('Role name is required', 400) + vals = {'name': name, 'entity_id': entity_id} + if body.get('permission_ids'): + vals['permission_ids'] = [(6, 0, [int(p) for p in body['permission_ids']])] + try: + role = request.env['encoach.role'].sudo().create(vals) + return _json_response({'data': _role_to_dict(role)}, 201) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py b/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py new file mode 100644 index 00000000..42be7d20 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py @@ -0,0 +1,312 @@ +import json +import logging +from datetime import datetime + +from odoo import http +from odoo.http import request + +from odoo.addons.encoach_api.controllers.base import ( + validate_token, _json_response as _base_json_response, _error_response, +) + +_logger = logging.getLogger(__name__) + + +def _json_response(data, status=200): + return request.make_json_response(data, status=status) + + +def _require_jwt(): + """Validate JWT and set the user context. Return user or error response.""" + user = validate_token() + if not user: + return None, _error_response("Authentication required", status=401) + request.update_env(user=user.id) + return user, None + + +def _normalize_dt(val): + """Convert ISO datetime strings (with T) to Odoo-compatible format.""" + if not val or not isinstance(val, str): + return val + return val.replace('T', ' ').replace('Z', '').split('+')[0] + + +def _schedule_to_dict(rec): + return { + 'id': rec.id, + 'name': rec.name, + 'exam_id': rec.exam_id.id if rec.exam_id else None, + 'exam_title': rec.exam_id.title if rec.exam_id else '', + 'entity_id': rec.entity_id.id if rec.entity_id else None, + 'entity_name': rec.entity_id.name if rec.entity_id else '', + 'start_date': rec.start_date.isoformat() if rec.start_date else None, + 'end_date': rec.end_date.isoformat() if rec.end_date else None, + 'state': rec.state, + 'assign_mode': rec.assign_mode, + 'full_length': rec.full_length, + 'generate_different': rec.generate_different, + 'auto_release_results': rec.auto_release_results, + 'auto_start': rec.auto_start, + 'official_exam': rec.official_exam, + 'hide_assignee_details': rec.hide_assignee_details, + 'batch_ids': rec.batch_ids.ids, + 'batch_names': [b.name for b in rec.batch_ids], + 'student_ids': rec.student_ids.ids, + 'assignee_count': rec.assignee_count, + 'completed_count': rec.completed_count, + 'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '', + } + + +class EncoachExamScheduleController(http.Controller): + + @http.route('/api/exam-schedules', type='http', auth='none', + methods=['GET'], csrf=False) + def list_schedules(self, **kw): + try: + user, err = _require_jwt() + if err: + return err + Schedule = request.env['encoach.exam.schedule'].sudo() + domain = [] + state_filter = kw.get('state') + if state_filter: + domain.append(('state', '=', state_filter)) + + limit = int(kw.get('limit', 50)) + offset = int(kw.get('offset', 0)) + total = Schedule.search_count(domain) + records = Schedule.search(domain, limit=limit, offset=offset, + order='start_date desc') + return _json_response({ + 'items': [_schedule_to_dict(r) for r in records], + 'total': total, + }) + except Exception as e: + _logger.exception('exam-schedules list failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/exam-schedules', type='http', auth='none', + methods=['POST'], csrf=False) + def create_schedule(self, **kw): + try: + user, err = _require_jwt() + if err: + return err + body = json.loads(request.httprequest.data or '{}') + name = body.get('name', '').strip() + exam_id = body.get('exam_id') + if not name or not exam_id: + return _json_response({'error': 'name and exam_id are required'}, 400) + + start_date = _normalize_dt(body.get('start_date')) + end_date = _normalize_dt(body.get('end_date')) + if not start_date or not end_date: + return _json_response({'error': 'start_date and end_date are required'}, 400) + + now = datetime.now() + try: + sd = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S') + except ValueError: + sd = datetime.strptime(start_date[:19], '%Y-%m-%d %H:%M:%S') + try: + ed = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S') + except ValueError: + ed = datetime.strptime(end_date[:19], '%Y-%m-%d %H:%M:%S') + + if sd <= now and ed > now: + initial_state = 'active' + elif sd > now: + initial_state = 'planned' + else: + initial_state = 'past' + + vals = { + 'name': name, + 'exam_id': int(exam_id), + 'entity_id': body.get('entity_id') and int(body['entity_id']) or False, + 'start_date': start_date, + 'end_date': end_date, + 'state': initial_state, + 'assign_mode': body.get('assign_mode', 'batch'), + 'full_length': body.get('full_length', True), + 'generate_different': body.get('generate_different', False), + 'auto_release_results': body.get('auto_release_results', False), + 'auto_start': body.get('auto_start', False), + 'official_exam': body.get('official_exam', False), + 'hide_assignee_details': body.get('hide_assignee_details', False), + } + + batch_ids = body.get('batch_ids', []) + if batch_ids: + vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])] + + student_ids = body.get('student_ids', []) + if student_ids: + vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])] + + rec = request.env['encoach.exam.schedule'].sudo().create(vals) + + self._create_individual_assignments(rec) + + return _json_response(_schedule_to_dict(rec), 201) + except Exception as e: + _logger.exception('exam-schedule create failed') + return _json_response({'error': str(e)}, 500) + + def _create_individual_assignments(self, schedule): + """Create individual assignment records for each targeted student.""" + Assignment = request.env['encoach.exam.assignment'].sudo() + created_user_ids = set() + + if schedule.student_ids: + for user in schedule.student_ids: + if user.id not in created_user_ids: + Assignment.create({ + 'exam_id': schedule.exam_id.id, + 'schedule_id': schedule.id, + 'student_id': user.id, + 'access_start': schedule.start_date, + 'access_end': schedule.end_date, + 'status': 'assigned', + }) + created_user_ids.add(user.id) + + for batch in schedule.batch_ids: + try: + students = request.env['op.student'].sudo().search([('batch_id', '=', batch.id)]) + for student in students: + user = student.user_id if hasattr(student, 'user_id') else None + if user and user.id not in created_user_ids: + Assignment.create({ + 'exam_id': schedule.exam_id.id, + 'schedule_id': schedule.id, + 'student_id': user.id, + 'batch_id': batch.id, + 'access_start': schedule.start_date, + 'access_end': schedule.end_date, + 'status': 'assigned', + }) + created_user_ids.add(user.id) + except Exception: + _logger.warning('Batch student lookup failed for batch %s', batch.id) + + if schedule.assign_mode == 'entity' and schedule.entity_id: + entity_users = schedule.entity_id.user_ids + for user in entity_users: + if user.id not in created_user_ids: + Assignment.create({ + 'exam_id': schedule.exam_id.id, + 'schedule_id': schedule.id, + 'student_id': user.id, + 'access_start': schedule.start_date, + 'access_end': schedule.end_date, + 'status': 'assigned', + }) + created_user_ids.add(user.id) + + @http.route('/api/exam-schedules/', type='http', auth='none', + methods=['PUT'], csrf=False) + def update_schedule(self, schedule_id, **kw): + try: + user, err = _require_jwt() + if err: + return err + rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id) + if not rec.exists(): + return _json_response({'error': 'Not found'}, 404) + + body = json.loads(request.httprequest.data or '{}') + vals = {} + for f in ('name', 'assign_mode', + 'full_length', 'generate_different', 'auto_release_results', + 'auto_start', 'official_exam', 'hide_assignee_details'): + if f in body: + vals[f] = body[f] + if 'start_date' in body: + vals['start_date'] = _normalize_dt(body['start_date']) + if 'end_date' in body: + vals['end_date'] = _normalize_dt(body['end_date']) + if 'entity_id' in body: + vals['entity_id'] = body['entity_id'] and int(body['entity_id']) or False + if 'batch_ids' in body: + vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])] + if 'student_ids' in body: + vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])] + if 'state' in body: + vals['state'] = body['state'] + if vals: + rec.write(vals) + return _json_response(_schedule_to_dict(rec)) + except Exception as e: + _logger.exception('exam-schedule update failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/exam-schedules/', type='http', auth='none', + methods=['DELETE'], csrf=False) + def delete_schedule(self, schedule_id, **kw): + try: + user, err = _require_jwt() + if err: + return err + rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id) + if not rec.exists(): + return _json_response({'error': 'Not found'}, 404) + rec.assignment_ids.unlink() + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('exam-schedule delete failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/exam-schedules//archive', type='http', auth='none', + methods=['POST'], csrf=False) + def archive_schedule(self, schedule_id, **kw): + try: + user, err = _require_jwt() + if err: + return err + rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id) + if not rec.exists(): + return _json_response({'error': 'Not found'}, 404) + rec.write({'state': 'archived'}) + return _json_response(_schedule_to_dict(rec)) + except Exception as e: + _logger.exception('exam-schedule archive failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/student/my-exams', type='http', auth='none', + methods=['GET'], csrf=False) + def student_my_exams(self, **kw): + """Return exam assignments for the current student user.""" + try: + user, err = _require_jwt() + if err: + return err + Assignment = request.env['encoach.exam.assignment'].sudo() + assignments = Assignment.search([ + ('student_id', '=', user.id), + ('status', 'in', ['assigned', 'started']), + ], order='access_start asc') + + result = [] + for a in assignments: + schedule = a.schedule_id + state = schedule.state if schedule else 'active' + result.append({ + 'id': a.id, + 'exam_id': a.exam_id.id, + 'exam_title': a.exam_id.title if a.exam_id else '', + 'schedule_name': schedule.name if schedule else '', + 'start_date': a.access_start.isoformat() if a.access_start else None, + 'end_date': a.access_end.isoformat() if a.access_end else None, + 'status': a.status, + 'schedule_state': state, + 'auto_start': schedule.auto_start if schedule else False, + 'can_start': state == 'active' and a.status == 'assigned', + }) + return _json_response({'items': result}) + except Exception as e: + _logger.exception('student my-exams failed') + return _json_response({'error': str(e)}, 500) diff --git a/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py b/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py index c94710da..ee53e3bf 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py +++ b/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py @@ -3,24 +3,17 @@ import logging from odoo import http from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) _logger = logging.getLogger(__name__) -def _json_body(): - try: - return json.loads(request.httprequest.data or '{}') - except Exception: - return {} - - -def _json_response(data, status=200): - return request.make_json_response(data, status=status) - - class ExamStructureController(http.Controller): - @http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False) + @http.route('/api/exam-structures', type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required def list_structures(self, **kw): domain = [('active', '=', True)] entity_id = kw.get('entity_id') @@ -29,8 +22,9 @@ class ExamStructureController(http.Controller): limit = int(kw.get('limit', 50)) offset = int(kw.get('offset', 0)) - records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc') - total = request.env['encoach.exam.structure'].search_count(domain) + records = request.env['encoach.exam.structure'].sudo().search( + domain, limit=limit, offset=offset, order='create_date desc') + total = request.env['encoach.exam.structure'].sudo().search_count(domain) items = [] for r in records: @@ -52,12 +46,13 @@ class ExamStructureController(http.Controller): return _json_response({'items': items, 'total': total}) - @http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False) + @http.route('/api/exam-structures', type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required def create_structure(self, **kw): - body = _json_body() + body = _get_json_body() name = body.get('name') if not name: - return _json_response({'error': 'name is required'}, status=400) + return _error_response('name is required', 400) vals = { 'name': name, @@ -69,19 +64,61 @@ class ExamStructureController(http.Controller): if entity_id: vals['entity_id'] = int(entity_id) - record = request.env['encoach.exam.structure'].create(vals) + record = request.env['encoach.exam.structure'].sudo().create(vals) return _json_response({ 'id': record.id, 'name': record.name, 'entity_id': record.entity_id.id if record.entity_id else None, 'industry': record.industry or '', 'modules': json.loads(record.modules) if record.modules else [], + 'config': json.loads(record.config) if record.config else {}, }) - @http.route('/api/exam-structures/', type='http', auth='user', methods=['DELETE'], csrf=False) - def delete_structure(self, structure_id, **kw): - record = request.env['encoach.exam.structure'].browse(structure_id) + @http.route('/api/exam-structures/', type='http', auth='none', methods=['PUT'], csrf=False) + @jwt_required + def update_structure(self, structure_id, **kw): + record = request.env['encoach.exam.structure'].sudo().browse(structure_id) if not record.exists(): - return _json_response({'error': 'Structure not found'}, status=404) + return _error_response('Structure not found', 404) + + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'industry' in body: + vals['industry'] = body['industry'] + if 'modules' in body: + vals['modules'] = json.dumps(body['modules']) + if 'config' in body: + vals['config'] = json.dumps(body['config']) + if 'entity_id' in body: + vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False + + if vals: + record.write(vals) + + modules = [] + if record.modules: + try: + modules = json.loads(record.modules) + except Exception: + modules = [] + + return _json_response({ + 'id': record.id, + 'name': record.name, + 'entity_id': record.entity_id.id if record.entity_id else None, + 'entity_name': record.entity_id.name if record.entity_id else None, + 'industry': record.industry or '', + 'modules': modules, + 'config': json.loads(record.config) if record.config else {}, + }) + + @http.route('/api/exam-structures/', type='http', auth='none', methods=['DELETE'], csrf=False) + @jwt_required + def delete_structure(self, structure_id, **kw): + record = request.env['encoach.exam.structure'].sudo().browse(structure_id) + if not record.exists(): + return _error_response('Structure not found', 404) record.unlink() return _json_response({'success': True}) diff --git a/backend/custom_addons/encoach_exam_template/controllers/review_workflow.py b/backend/custom_addons/encoach_exam_template/controllers/review_workflow.py new file mode 100644 index 00000000..5d740c00 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/review_workflow.py @@ -0,0 +1,256 @@ +"""Human-in-the-loop review workflow for AI-generated exams. + +When an exam is created via the AI pipeline and fails the automated quality +gate (QualityChecker + IeltsValidator), it transitions to ``pending_review`` +instead of being published directly (see ADR 0004 and P1.1 in the hardening +release). This controller exposes the admin-facing endpoints used by the +review queue UI to inspect, approve, or reject those exams. + +Endpoints: + +* ``GET /api/exam/review/queue`` — paginated list of pending exams +* ``GET /api/exam/review/`` — detail with per-question quality data +* ``POST /api/exam/review//approve`` — publish (optional notes) +* ``POST /api/exam/review//reject`` — back to draft (notes required) +""" + +import json +import logging + +from odoo import fields, http +from odoo.http import request + +from odoo.addons.encoach_api.controllers.base import ( + _error_response, + _get_json_body, + _json_response, + _paginate, + jwt_required, +) + +_logger = logging.getLogger(__name__) + + +def _question_to_review_dict(q): + """Compact projection of a question for the review UI.""" + try: + report = json.loads(q.quality_report) if q.quality_report else None + except (TypeError, ValueError): + report = {'raw': q.quality_report} + return { + 'id': q.id, + 'skill': q.skill, + 'question_type': q.question_type, + 'difficulty': q.difficulty, + 'stem': q.stem or '', + 'marks': q.marks or 0.0, + 'ai_generated': bool(q.ai_generated), + 'ielts_certified': bool(q.ielts_certified), + 'format_validated': bool(q.format_validated), + 'ai_model_used': q.ai_model_used or '', + 'ai_prompt_hash': q.ai_prompt_hash or '', + 'quality_score': q.quality_score or 0.0, + 'quality_report': report, + } + + +def _review_summary(exam): + """Aggregate quality stats across every question in the exam.""" + questions = exam.section_ids.mapped('question_ids') + total = len(questions) + if total == 0: + return { + 'question_count': 0, + 'avg_quality_score': 0.0, + 'min_quality_score': 0.0, + 'failing_count': 0, + 'ai_generated_count': 0, + } + scores = [q.quality_score or 0.0 for q in questions] + failing_threshold = 0.7 # configurable later via ir.config_parameter + return { + 'question_count': total, + 'avg_quality_score': round(sum(scores) / total, 3), + 'min_quality_score': round(min(scores), 3), + 'failing_count': sum(1 for s in scores if s < failing_threshold), + 'ai_generated_count': sum(1 for q in questions if q.ai_generated), + } + + +def _exam_to_review_dict(exam, *, include_questions=False): + data = { + 'id': exam.id, + 'title': exam.title, + 'status': exam.status, + 'subject_id': exam.subject_id.id if exam.subject_id else None, + 'subject_name': exam.subject_id.name if exam.subject_id else '', + 'entity_id': exam.entity_id.id if exam.entity_id else None, + 'teacher_id': exam.teacher_id.id if exam.teacher_id else None, + 'teacher_name': exam.teacher_id.name if exam.teacher_id else '', + 'grading_system': exam.grading_system, + 'total_time_min': exam.total_time_min or 0, + 'total_marks': exam.total_marks or 0.0, + 'reviewed_by_id': exam.reviewed_by_id.id if exam.reviewed_by_id else None, + 'reviewed_by_name': exam.reviewed_by_id.name if exam.reviewed_by_id else '', + 'reviewed_at': ( + fields.Datetime.to_string(exam.reviewed_at) if exam.reviewed_at else None + ), + 'review_notes': exam.review_notes or '', + 'summary': _review_summary(exam), + } + if include_questions: + data['sections'] = [] + for sec in exam.section_ids: + data['sections'].append({ + 'id': sec.id, + 'title': sec.title, + 'skill': sec.skill or '', + 'sequence': sec.sequence, + 'questions': [ + _question_to_review_dict(q) for q in sec.question_ids + ], + }) + return data + + +class EncoachExamReviewController(http.Controller): + """Admin review queue for AI-generated exams gated by the quality checker.""" + + # ------------------------------------------------------------------ + # GET /api/exam/review/queue + # ------------------------------------------------------------------ + @http.route('/api/exam/review/queue', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def queue(self, **kw): + try: + Exam = request.env['encoach.exam.custom'].sudo() + # Default to pending_review but allow callers to inspect history too. + status = (kw.get('status') or 'pending_review').strip() + domain = [('status', '=', status)] if status else [] + + search = (kw.get('search') or '').strip() + if search: + domain.append(('title', 'ilike', search)) + + subject_id = kw.get('subject_id') + if subject_id: + try: + domain.append(('subject_id', '=', int(subject_id))) + except (TypeError, ValueError): + pass + + total = Exam.search_count(domain) + page, per_page, offset = _paginate(kw) + exams = Exam.search( + domain, limit=per_page, offset=offset, order='id desc', + ) + items = [_exam_to_review_dict(e) for e in exams] + return _json_response({ + 'items': items, + 'data': items, + 'total': total, + 'page': page, + 'size': per_page, + }) + except Exception as e: + _logger.exception('exam review queue failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/exam/review/ + # ------------------------------------------------------------------ + @http.route('/api/exam/review/', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def detail(self, exam_id, **kw): + try: + exam = request.env['encoach.exam.custom'].sudo().browse(exam_id) + if not exam.exists(): + return _error_response('Exam not found', 404) + return _json_response( + _exam_to_review_dict(exam, include_questions=True) + ) + except Exception as e: + _logger.exception('exam review detail failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/exam/review//approve + # ------------------------------------------------------------------ + @http.route('/api/exam/review//approve', type='http', + auth='none', methods=['POST'], csrf=False) + @jwt_required + def approve(self, exam_id, **kw): + try: + exam = request.env['encoach.exam.custom'].sudo().browse(exam_id) + if not exam.exists(): + return _error_response('Exam not found', 404) + if exam.status != 'pending_review': + return _error_response( + "Only exams in 'pending_review' can be approved " + f"(current status: {exam.status})", + 400, + ) + + body = _get_json_body() or {} + notes = (body.get('notes') or '').strip() or False + with request.env.cr.savepoint(): + exam.write({ + 'status': 'published', + 'reviewed_by_id': request.env.user.id, + 'reviewed_at': fields.Datetime.now(), + 'review_notes': notes, + }) + _logger.info( + 'exam %s approved by user %s', exam.id, request.env.user.id, + ) + return _json_response( + _exam_to_review_dict(exam, include_questions=False) + ) + except Exception as e: + _logger.exception('exam review approve failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/exam/review//reject + # ------------------------------------------------------------------ + @http.route('/api/exam/review//reject', type='http', + auth='none', methods=['POST'], csrf=False) + @jwt_required + def reject(self, exam_id, **kw): + try: + exam = request.env['encoach.exam.custom'].sudo().browse(exam_id) + if not exam.exists(): + return _error_response('Exam not found', 404) + if exam.status != 'pending_review': + return _error_response( + "Only exams in 'pending_review' can be rejected " + f"(current status: {exam.status})", + 400, + ) + + body = _get_json_body() or {} + notes = (body.get('notes') or '').strip() + # Require notes on rejection — the author needs actionable feedback. + if not notes: + return _error_response( + 'Review notes are required when rejecting an exam.', 400, + ) + + with request.env.cr.savepoint(): + exam.write({ + 'status': 'draft', + 'reviewed_by_id': request.env.user.id, + 'reviewed_at': fields.Datetime.now(), + 'review_notes': notes, + }) + _logger.info( + 'exam %s rejected by user %s', exam.id, request.env.user.id, + ) + return _json_response( + _exam_to_review_dict(exam, include_questions=False) + ) + except Exception as e: + _logger.exception('exam review reject failed') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_exam_template/controllers/rubrics.py b/backend/custom_addons/encoach_exam_template/controllers/rubrics.py index ae9ba783..370497d7 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/rubrics.py +++ b/backend/custom_addons/encoach_exam_template/controllers/rubrics.py @@ -3,14 +3,13 @@ import logging from odoo import http from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) _logger = logging.getLogger(__name__) -def _json_response(data, status=200): - return request.make_json_response(data, status=status) - - def _rubric_to_dict(rec): criteria_text = rec.criteria or '' criteria_count = 0 @@ -26,6 +25,15 @@ def _rubric_to_dict(rec): except (json.JSONDecodeError, ValueError): criteria_count = len([l for l in criteria_text.split('\n') if l.strip()]) + levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + if rec.levels: + try: + parsed_levels = json.loads(rec.levels) + if isinstance(parsed_levels, list) and parsed_levels: + levels = parsed_levels + except (json.JSONDecodeError, ValueError): + pass + return { 'id': rec.id, 'name': rec.name, @@ -33,15 +41,16 @@ def _rubric_to_dict(rec): 'exam_type': rec.exam_type or '', 'criteria': criteria_count or 1, 'criteria_text': criteria_text, - 'levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'], + 'levels': levels, 'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '', } class EncoachRubricController(http.Controller): - @http.route('/api/rubrics', type='http', auth='user', + @http.route('/api/rubrics', type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required def list_rubrics(self, **kw): try: Rubric = request.env['encoach.rubric'].sudo() @@ -58,14 +67,15 @@ class EncoachRubricController(http.Controller): _logger.exception('rubrics list failed') return _json_response({'error': str(e)}, 500) - @http.route('/api/rubrics', type='http', auth='user', + @http.route('/api/rubrics', type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required def create_rubric(self, **kw): try: - body = json.loads(request.httprequest.data or '{}') + body = _get_json_body() name = body.get('name', '').strip() if not name: - return _json_response({'error': 'name is required'}, 400) + return _error_response('name is required', 400) vals = { 'name': name, @@ -73,8 +83,140 @@ class EncoachRubricController(http.Controller): 'criteria': body.get('criteria', ''), 'exam_type': body.get('exam_type', 'academic'), } - rec = Rubric = request.env['encoach.rubric'].sudo().create(vals) + if 'levels' in body: + vals['levels'] = json.dumps(body['levels']) + rec = request.env['encoach.rubric'].sudo().create(vals) return _json_response(_rubric_to_dict(rec), 201) except Exception as e: _logger.exception('rubric create failed') return _json_response({'error': str(e)}, 500) + + @http.route('/api/rubrics/', type='http', auth='none', + methods=['PUT'], csrf=False) + @jwt_required + def update_rubric(self, rubric_id, **kw): + try: + rec = request.env['encoach.rubric'].sudo().browse(rubric_id) + if not rec.exists(): + return _error_response('Rubric not found', 404) + + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'skill' in body: + vals['skill'] = body['skill'] + if 'criteria' in body: + vals['criteria'] = body['criteria'] + if 'exam_type' in body: + vals['exam_type'] = body['exam_type'] + if 'levels' in body: + vals['levels'] = json.dumps(body['levels']) + + if vals: + rec.write(vals) + + return _json_response(_rubric_to_dict(rec)) + except Exception as e: + _logger.exception('rubric update failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/rubrics/', type='http', auth='none', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_rubric(self, rubric_id, **kw): + try: + rec = request.env['encoach.rubric'].sudo().browse(rubric_id) + if not rec.exists(): + return _error_response('Rubric not found', 404) + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('rubric delete failed') + return _json_response({'error': str(e)}, 500) + + +def _group_to_dict(rec): + return { + 'id': rec.id, + 'name': rec.name, + 'rubric_ids': rec.rubric_ids.ids, + 'rubric_names': [r.name for r in rec.rubric_ids], + 'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '', + } + + +class EncoachRubricGroupController(http.Controller): + + @http.route('/api/rubric-groups', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def list_rubric_groups(self, **kw): + try: + Group = request.env['encoach.rubric.group'].sudo() + limit = int(kw.get('limit', 50)) + offset = int(kw.get('offset', 0)) + records = Group.search([], limit=limit, offset=offset, + order='create_date desc') + total = Group.search_count([]) + return _json_response({ + 'items': [_group_to_dict(r) for r in records], + 'total': total, + }) + except Exception as e: + _logger.exception('rubric-groups list failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/rubric-groups', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def create_rubric_group(self, **kw): + try: + body = _get_json_body() + name = body.get('name', '').strip() + if not name: + return _error_response('name is required', 400) + rubric_ids = body.get('rubric_ids', []) + rec = request.env['encoach.rubric.group'].sudo().create({ + 'name': name, + 'rubric_ids': [(6, 0, rubric_ids)], + }) + return _json_response(_group_to_dict(rec), 201) + except Exception as e: + _logger.exception('rubric-group create failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/rubric-groups/', type='http', auth='none', + methods=['PUT'], csrf=False) + @jwt_required + def update_rubric_group(self, group_id, **kw): + try: + rec = request.env['encoach.rubric.group'].sudo().browse(group_id) + if not rec.exists(): + return _error_response('Group not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'rubric_ids' in body: + vals['rubric_ids'] = [(6, 0, body['rubric_ids'])] + if vals: + rec.write(vals) + return _json_response(_group_to_dict(rec)) + except Exception as e: + _logger.exception('rubric-group update failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/rubric-groups/', type='http', auth='none', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_rubric_group(self, group_id, **kw): + try: + rec = request.env['encoach.rubric.group'].sudo().browse(group_id) + if not rec.exists(): + return _error_response('Group not found', 404) + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('rubric-group delete failed') + return _json_response({'error': str(e)}, 500) diff --git a/backend/custom_addons/encoach_exam_template/data/ir_cron_schedule.xml b/backend/custom_addons/encoach_exam_template/data/ir_cron_schedule.xml new file mode 100644 index 00000000..090757c5 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/data/ir_cron_schedule.xml @@ -0,0 +1,14 @@ + + + + + Exam Schedule Lifecycle + + code + model.update_lifecycle_states() + 1 + minutes + True + + + diff --git a/backend/custom_addons/encoach_exam_template/models/__init__.py b/backend/custom_addons/encoach_exam_template/models/__init__.py index 0479cdc5..da8639d1 100644 --- a/backend/custom_addons/encoach_exam_template/models/__init__.py +++ b/backend/custom_addons/encoach_exam_template/models/__init__.py @@ -1,4 +1,5 @@ from . import rubric +from . import rubric_group from . import exam_template from . import passage from . import audio_file @@ -8,4 +9,6 @@ from . import speaking_card from . import exam_custom from . import exam_custom_section from . import exam_assignment +from . import exam_schedule from . import exam_structure +from . import approval diff --git a/backend/custom_addons/encoach_exam_template/models/approval.py b/backend/custom_addons/encoach_exam_template/models/approval.py new file mode 100644 index 00000000..c2bf18c5 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/models/approval.py @@ -0,0 +1,95 @@ +"""Approval workflow models. + +Powers the admin "Approval Config" page (`/admin/approval-config`) and the +per-record approval requests exposed by `ApprovalWorkflowController`. + +Prior to this module the controller hand-rolled raw SQL against tables that +were never created. These ORM models now own the schema so Odoo can create +the tables on install/update and the controller can use a standard API. +""" +from odoo import api, fields, models + + +STATUS_SELECTION = [ + ('draft', 'Draft'), + ('active', 'Active'), + ('archived', 'Archived'), +] + +REQUEST_STATE = [ + ('draft', 'Draft'), + ('in_progress', 'In Progress'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('bypassed', 'Bypassed'), +] + +STAGE_STATUS = [ + ('pending', 'Pending'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('skipped', 'Skipped'), +] + + +class EncoachApprovalWorkflow(models.Model): + _name = 'encoach.approval.workflow' + _description = 'Approval Workflow' + _order = 'create_date desc' + + name = fields.Char(required=True) + type = fields.Char(default='custom', + help='Categorical tag e.g. leave, purchase, content, custom.') + status = fields.Selection(STATUS_SELECTION, default='draft', required=True, + help='Draft = not yet routable. Active = available to new requests.') + allow_bypass = fields.Boolean( + default=False, + help='If true, a bypass reason allows the workflow to be skipped.') + entity_id = fields.Many2one('encoach.entity', ondelete='set null') + stage_ids = fields.One2many('encoach.approval.stage', 'workflow_id', + copy=True) + request_ids = fields.One2many('encoach.approval.request', 'workflow_id') + stage_count = fields.Integer(compute='_compute_counts') + request_count = fields.Integer(compute='_compute_counts') + + @api.depends('stage_ids', 'request_ids') + def _compute_counts(self): + for rec in self: + rec.stage_count = len(rec.stage_ids) + rec.request_count = len(rec.request_ids) + + +class EncoachApprovalStage(models.Model): + _name = 'encoach.approval.stage' + _description = 'Approval Workflow Stage' + _order = 'sequence, id' + + workflow_id = fields.Many2one('encoach.approval.workflow', + required=True, ondelete='cascade', index=True) + sequence = fields.Integer(default=10, required=True, + help='Stored order; the admin UI speaks `order`.') + approver_id = fields.Many2one('res.users', required=True, ondelete='restrict') + max_days = fields.Integer(default=3, + help='SLA before auto-escalation can fire.') + auto_escalate = fields.Boolean(default=False) + notification_email = fields.Char( + help='Optional CC address; falls back to approver_id.email otherwise.') + status = fields.Selection(STAGE_STATUS, default='pending') + comment = fields.Text() + acted_at = fields.Datetime() + + +class EncoachApprovalRequest(models.Model): + _name = 'encoach.approval.request' + _description = 'Approval Request' + _order = 'create_date desc' + + workflow_id = fields.Many2one('encoach.approval.workflow', + required=True, ondelete='cascade', index=True) + res_model = fields.Char(help='Optional target model (e.g. op.student.leave).') + res_id = fields.Integer(help='Optional target record id inside res_model.') + state = fields.Selection(REQUEST_STATE, default='draft', required=True) + requester_id = fields.Many2one('res.users', ondelete='set null') + current_stage_id = fields.Many2one('encoach.approval.stage', ondelete='set null') + bypass_reason = fields.Text() + created_at = fields.Datetime(default=fields.Datetime.now) diff --git a/backend/custom_addons/encoach_exam_template/models/exam_assignment.py b/backend/custom_addons/encoach_exam_template/models/exam_assignment.py index a2fc40cd..bd105492 100644 --- a/backend/custom_addons/encoach_exam_template/models/exam_assignment.py +++ b/backend/custom_addons/encoach_exam_template/models/exam_assignment.py @@ -6,6 +6,7 @@ class EncoachExamAssignment(models.Model): _description = 'Exam Assignment' exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade') + schedule_id = fields.Many2one('encoach.exam.schedule', ondelete='cascade') student_id = fields.Many2one('res.users', ondelete='cascade') batch_id = fields.Many2one('op.batch', ondelete='set null') access_start = fields.Datetime() diff --git a/backend/custom_addons/encoach_exam_template/models/exam_custom.py b/backend/custom_addons/encoach_exam_template/models/exam_custom.py index 7efc2596..a56c74b2 100644 --- a/backend/custom_addons/encoach_exam_template/models/exam_custom.py +++ b/backend/custom_addons/encoach_exam_template/models/exam_custom.py @@ -6,13 +6,32 @@ class EncoachExamCustom(models.Model): _description = 'Custom Exam' title = fields.Char(size=200, required=True) + label = fields.Char(size=100) + exam_mode = fields.Selection([ + ('official', 'Official'), + ('practice', 'Practice'), + ], default='official') template_id = fields.Many2one('encoach.exam.template', ondelete='set null') + structure_id = fields.Many2one('encoach.exam.structure', ondelete='set null') subject_id = fields.Many2one('encoach.subject', ondelete='set null') entity_id = fields.Many2one('encoach.entity', ondelete='set null') teacher_id = fields.Many2one('res.users', ondelete='set null') + rubric_id = fields.Many2one('encoach.rubric', ondelete='set null') + approval_workflow_id = fields.Integer() description = fields.Text() total_time_min = fields.Integer() + total_marks = fields.Float() pass_threshold = fields.Float() + grading_system = fields.Selection([ + ('ielts', 'IELTS Band'), + ('percentage', 'Percentage'), + ('pass_fail', 'Pass / Fail'), + ('cefr', 'CEFR Level'), + ], default='ielts') + access_type = fields.Selection([ + ('private', 'Private'), + ('public', 'Public'), + ], default='private') results_release_mode = fields.Selection([ ('auto', 'Auto'), ('manual_approval', 'Manual Approval'), @@ -20,7 +39,22 @@ class EncoachExamCustom(models.Model): randomize_questions = fields.Boolean(default=False) status = fields.Selection([ ('draft', 'Draft'), + ('pending_review', 'Pending Review'), ('published', 'Published'), ('archived', 'Archived'), ], default='draft', required=True) section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id') + + # Human-in-the-loop review audit trail. Populated by the + # /api/exam/review//(approve|reject) endpoints so we know who signed + # off on an AI-generated exam and what their reasoning was. + reviewed_by_id = fields.Many2one( + 'res.users', ondelete='set null', + string='Reviewed By', + readonly=True, + ) + reviewed_at = fields.Datetime(string='Reviewed At', readonly=True) + review_notes = fields.Text( + string='Review Notes', + help='Approver or rejecter comments. Required when rejecting back to draft.', + ) diff --git a/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py b/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py index 34836752..e74c7074 100644 --- a/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py +++ b/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py @@ -1,3 +1,4 @@ +import json from odoo import models, fields @@ -8,14 +9,19 @@ class EncoachExamCustomSection(models.Model): exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade') title = fields.Char(size=200, required=True) skill = fields.Char(size=100) + difficulty = fields.Char(size=50) question_count = fields.Integer() time_limit_min = fields.Integer() + total_marks = fields.Float() scoring_method = fields.Selection([ ('auto', 'Auto'), ('rubric', 'Rubric'), ('mixed', 'Mixed'), ], default='auto') sequence = fields.Integer(default=10) + passage_text = fields.Text() + instructions_text = fields.Text() + content_json = fields.Text(help='JSON blob for tasks/parts/passages/sections config') question_ids = fields.Many2many( 'encoach.question', 'exam_custom_section_question_rel', diff --git a/backend/custom_addons/encoach_exam_template/models/exam_schedule.py b/backend/custom_addons/encoach_exam_template/models/exam_schedule.py new file mode 100644 index 00000000..bceb95a2 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/models/exam_schedule.py @@ -0,0 +1,68 @@ +from odoo import models, fields, api +from datetime import datetime + + +class EncoachExamSchedule(models.Model): + _name = 'encoach.exam.schedule' + _description = 'Exam Schedule / Assignment Group' + _order = 'create_date desc' + + name = fields.Char(required=True, size=200) + exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade') + entity_id = fields.Many2one('encoach.entity', ondelete='set null') + + start_date = fields.Datetime(required=True) + end_date = fields.Datetime(required=True) + + full_length = fields.Boolean(default=True) + generate_different = fields.Boolean(default=False) + auto_release_results = fields.Boolean(default=False) + auto_start = fields.Boolean(default=False) + official_exam = fields.Boolean(default=False) + hide_assignee_details = fields.Boolean(default=False) + + assign_mode = fields.Selection([ + ('entity', 'Entire Entity'), + ('batch', 'Class / Batch'), + ('individual', 'Individual Students'), + ], default='batch', required=True) + + batch_ids = fields.Many2many('op.batch', string='Classes') + student_ids = fields.Many2many('res.users', 'exam_schedule_student_rel', + 'schedule_id', 'user_id', string='Students') + + state = fields.Selection([ + ('planned', 'Planned'), + ('active', 'Active'), + ('past', 'Past'), + ('start_expired', 'Start Expired'), + ('archived', 'Archived'), + ], default='planned', required=True, index=True) + + assignment_ids = fields.One2many('encoach.exam.assignment', 'schedule_id') + assignee_count = fields.Integer(compute='_compute_counts', store=True) + completed_count = fields.Integer(compute='_compute_counts', store=True) + + @api.depends('assignment_ids', 'assignment_ids.status') + def _compute_counts(self): + for rec in self: + assignments = rec.assignment_ids + rec.assignee_count = len(assignments) + rec.completed_count = len(assignments.filtered(lambda a: a.status == 'completed')) + + def update_lifecycle_states(self): + """Cron job: transition schedules based on current time.""" + now = datetime.now() + + planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '>', now)]) + planned.write({'state': 'active'}) + + expired_planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '<=', now)]) + expired_planned.write({'state': 'start_expired'}) + + active_ended = self.search([('state', '=', 'active'), ('end_date', '<=', now)]) + active_ended.write({'state': 'past'}) + for rec in active_ended: + rec.assignment_ids.filtered( + lambda a: a.status in ('assigned', 'started') + ).write({'status': 'expired'}) diff --git a/backend/custom_addons/encoach_exam_template/models/question.py b/backend/custom_addons/encoach_exam_template/models/question.py index 21f0b060..e3c214c3 100644 --- a/backend/custom_addons/encoach_exam_template/models/question.py +++ b/backend/custom_addons/encoach_exam_template/models/question.py @@ -1,4 +1,8 @@ -from odoo import models, fields +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) class EncoachQuestion(models.Model): @@ -67,3 +71,55 @@ class EncoachQuestion(models.Model): format_validated = fields.Boolean(default=False) subject_id = fields.Many2one('encoach.subject', ondelete='set null') topic_id = fields.Many2one('encoach.topic', ondelete='set null') + + ai_model_used = fields.Char( + string='AI Model', + help='LLM model that produced this question (e.g. gpt-4o-mini).', + index=True, + ) + ai_prompt_hash = fields.Char( + string='Prompt Hash', + help='SHA-256 hex digest of the rendered prompt used to generate this question.', + index=True, + ) + ai_log_id = fields.Integer( + string='AI Generation Log ID', + help='Row id in encoach.ai.generation.log (soft ref — no FK to avoid cross-module coupling).', + index=True, + ) + ai_generated_at = fields.Datetime(string='AI Generated At') + quality_score = fields.Float( + string='Quality Score', + help='Aggregate quality score from automated checkers (0-1).', + ) + quality_report = fields.Text( + string='Quality Report (JSON)', + help='JSON dump of the last QualityChecker + IeltsValidator run.', + ) + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + 'encoach_question_skill_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_question_skill_status_idx " + "ON encoach_question (skill, status)", + ), + ( + 'encoach_question_subject_difficulty_idx', + "CREATE INDEX IF NOT EXISTS encoach_question_subject_difficulty_idx " + "ON encoach_question (subject_id, difficulty) WHERE subject_id IS NOT NULL", + ), + ( + 'encoach_question_ai_prompt_hash_idx', + "CREATE INDEX IF NOT EXISTS encoach_question_ai_prompt_hash_idx " + "ON encoach_question (ai_prompt_hash) WHERE ai_prompt_hash IS NOT NULL", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res diff --git a/backend/custom_addons/encoach_exam_template/models/rubric.py b/backend/custom_addons/encoach_exam_template/models/rubric.py index 6a38ddf5..ad506b00 100644 --- a/backend/custom_addons/encoach_exam_template/models/rubric.py +++ b/backend/custom_addons/encoach_exam_template/models/rubric.py @@ -11,6 +11,7 @@ class EncoachRubric(models.Model): ('speaking', 'Speaking'), ], required=True) criteria = fields.Text(required=True) + levels = fields.Text(help='JSON list of CEFR levels, e.g. ["A1","A2","B1","B2","C1","C2"]') exam_type = fields.Selection([ ('academic', 'Academic'), ('general_training', 'General Training'), diff --git a/backend/custom_addons/encoach_exam_template/models/rubric_group.py b/backend/custom_addons/encoach_exam_template/models/rubric_group.py new file mode 100644 index 00000000..fa024357 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/models/rubric_group.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class EncoachRubricGroup(models.Model): + _name = 'encoach.rubric.group' + _description = 'Rubric Group' + + name = fields.Char(size=200, required=True) + rubric_ids = fields.Many2many( + 'encoach.rubric', + 'encoach_rubric_group_rel', + 'group_id', + 'rubric_id', + string='Rubrics', + ) diff --git a/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv b/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv index b38960e9..49699ff8 100644 --- a/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv +++ b/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv @@ -6,7 +6,12 @@ access_encoach_question_user,encoach.question.user,model_encoach_question,base.g access_encoach_writing_prompt_user,encoach.writing.prompt.user,model_encoach_writing_prompt,base.group_user,1,1,1,1 access_encoach_speaking_card_user,encoach.speaking.card.user,model_encoach_speaking_card,base.group_user,1,1,1,1 access_encoach_rubric_user,encoach.rubric.user,model_encoach_rubric,base.group_user,1,1,1,1 +access_encoach_rubric_group_user,encoach.rubric.group.user,model_encoach_rubric_group,base.group_user,1,1,1,1 access_encoach_exam_custom_user,encoach.exam.custom.user,model_encoach_exam_custom,base.group_user,1,1,1,1 access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_encoach_exam_custom_section,base.group_user,1,1,1,1 access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1 +access_encoach_exam_schedule_user,encoach.exam.schedule.user,model_encoach_exam_schedule,base.group_user,1,1,1,1 access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1 +access_encoach_approval_workflow_user,encoach.approval.workflow.user,model_encoach_approval_workflow,base.group_user,1,1,1,1 +access_encoach_approval_stage_user,encoach.approval.stage.user,model_encoach_approval_stage,base.group_user,1,1,1,1 +access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1 diff --git a/backend/custom_addons/encoach_lms_api/__init__.py b/backend/custom_addons/encoach_lms_api/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/backend/custom_addons/encoach_lms_api/__manifest__.py b/backend/custom_addons/encoach_lms_api/__manifest__.py new file mode 100644 index 00000000..904a2f8b --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/__manifest__.py @@ -0,0 +1,33 @@ +{ + 'name': 'EnCoach LMS API', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'REST API gateway for all LMS frontend pages', + 'author': 'EnCoach', + 'depends': [ + 'base', + 'mail', + 'openeducat_core', + 'openeducat_classroom', + 'openeducat_timetable', + 'openeducat_attendance', + 'openeducat_activity', + 'openeducat_admission', + 'openeducat_assignment', + 'openeducat_exam', + 'openeducat_facility', + 'openeducat_fees', + 'openeducat_library', + 'encoach_core', + 'encoach_api', + 'encoach_ai', + 'encoach_taxonomy', + 'encoach_resources', + ], + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/backend/custom_addons/encoach_lms_api/controllers/__init__.py b/backend/custom_addons/encoach_lms_api/controllers/__init__.py new file mode 100644 index 00000000..37d22d5d --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/__init__.py @@ -0,0 +1,28 @@ +from . import lms_core +from . import academic +from . import timetable +from . import attendance +from . import grades +from . import admissions +from . import fees +from . import library +from . import activities +from . import facilities +from . import lessons +from . import student_leave +from . import student_progress +from . import communication +from . import institutional_exams +from . import resources +from . import users_roles +from . import classrooms +from . import courseware +from . import notifications +from . import faq +from . import stats +from . import tickets +from . import payments +from . import paymob +from . import platform_settings +from . import training +from . import reports diff --git a/backend/custom_addons/encoach_lms_api/controllers/academic.py b/backend/custom_addons/encoach_lms_api/controllers/academic.py new file mode 100644 index 00000000..0f609446 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/academic.py @@ -0,0 +1,364 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_year(r): + terms = [] + if hasattr(r, 'academic_term_ids'): + for t in r.academic_term_ids: + terms.append(_ser_term(t)) + return { + 'id': r.id, + 'name': r.name or '', + 'start_date': str(r.start_date) if r.start_date else '', + 'end_date': str(r.end_date) if r.end_date else '', + 'term_structure': getattr(r, 'term_structure', 'others') or 'others', + 'terms': terms, + } + + +def _ser_term(t): + return { + 'id': t.id, + 'name': t.name or '', + 'term_start_date': str(t.term_start_date) if hasattr(t, 'term_start_date') and t.term_start_date else str(t.start_date) if hasattr(t, 'start_date') and t.start_date else '', + 'term_end_date': str(t.term_end_date) if hasattr(t, 'term_end_date') and t.term_end_date else str(t.end_date) if hasattr(t, 'end_date') and t.end_date else '', + 'academic_year_id': t.academic_year_id.id if hasattr(t, 'academic_year_id') and t.academic_year_id else 0, + 'academic_year_name': t.academic_year_id.name if hasattr(t, 'academic_year_id') and t.academic_year_id else '', + 'parent_term_id': t.parent_id.id if hasattr(t, 'parent_id') and t.parent_id else None, + 'parent_term_name': t.parent_id.name if hasattr(t, 'parent_id') and t.parent_id else None, + } + + +def _ser_dept(d): + env = d.env + course_count = 0 + faculty_count = 0 + try: + if 'op.course' in env: + Course = env['op.course'].sudo() + if 'department_id' in Course._fields: + course_count = Course.search_count([('department_id', '=', d.id)]) + if 'op.faculty' in env: + Faculty = env['op.faculty'].sudo() + if 'department_id' in Faculty._fields: + faculty_count = Faculty.search_count([('department_id', '=', d.id)]) + elif 'main_department_id' in Faculty._fields: + faculty_count = Faculty.search_count([('main_department_id', '=', d.id)]) + except Exception: + pass + return { + 'id': d.id, + 'name': d.name or '', + 'code': d.code or '' if hasattr(d, 'code') else '', + 'parent_id': d.parent_id.id if hasattr(d, 'parent_id') and d.parent_id else None, + 'parent_name': d.parent_id.name if hasattr(d, 'parent_id') and d.parent_id else None, + 'course_count': course_count, + 'faculty_count': faculty_count, + } + + +class AcademicController(http.Controller): + + # ── Academic Years ─────────────────────────────────────────────── + + @http.route('/api/academic-years', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_years(self, **kw): + try: + M = request.env['op.academic.year'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + return _json_response({'items': [_ser_year(r) for r in recs], 'data': [_ser_year(r) for r in recs], 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_years') + return _error_response(str(e), 500) + + @http.route('/api/academic-years/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_year(self, yid, **kw): + try: + rec = request.env['op.academic.year'].sudo().browse(yid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_year(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-years', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_year(self, **kw): + try: + body = _get_json_body() + Year = request.env['op.academic.year'].sudo() + vals = { + 'name': body.get('name', ''), + 'start_date': body.get('start_date'), + 'end_date': body.get('end_date'), + } + if body.get('term_structure') and 'term_structure' in Year._fields: + vals['term_structure'] = body['term_structure'] + rec = Year.create(vals) + return _json_response(_ser_year(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-years/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_year(self, yid, **kw): + try: + rec = request.env['op.academic.year'].sudo().browse(yid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + if 'term_structure' in body and 'term_structure' in rec._fields: + vals['term_structure'] = body['term_structure'] + if vals: + rec.write(vals) + return _json_response(_ser_year(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-years/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_year(self, yid, **kw): + try: + rec = request.env['op.academic.year'].sudo().browse(yid) + if rec.exists(): + # Unlink dependent terms first to avoid RESTRICT FK violation. + if hasattr(rec, 'academic_term_ids') and rec.academic_term_ids: + rec.academic_term_ids.unlink() + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_year') + return _error_response(str(e), 500) + + @http.route('/api/academic-years//generate-terms', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def generate_terms(self, yid, **kw): + """Create a set of op.academic.term records spanning the year. + The number of terms is dictated by the year's `term_structure`: + + two_sem → 2 semesters + two_sem_qua → 2 semesters + 4 quarters (nested under each sem) + three_sem → 3 semesters + four_Quarter → 4 quarters + final_year → 1 single term + others → 2 semesters (default fallback) + """ + try: + from datetime import timedelta + year = request.env['op.academic.year'].sudo().browse(yid) + if not year.exists(): + return _error_response('Not found', 404) + start = year.start_date + end = year.end_date + if not start or not end: + return _error_response('Year has no dates', 400) + total_days = max(1, (end - start).days) + structure = getattr(year, 'term_structure', 'two_sem') or 'two_sem' + body = _get_json_body() or {} + if body.get('replace'): + for existing in year.academic_term_ids: + existing.sudo().unlink() + + Term = request.env['op.academic.term'].sudo() + + def _split(n, label): + chunk = total_days // n + out = [] + for i in range(n): + s = start + timedelta(days=i * chunk) + e = end if i == n - 1 else start + timedelta(days=(i + 1) * chunk - 1) + out.append(Term.create({ + 'name': f'{year.name} - {label} {i + 1}', + 'term_start_date': str(s), + 'term_end_date': str(e), + 'academic_year_id': year.id, + })) + return out + + created = [] + if structure == 'two_sem': + created = _split(2, 'Semester') + elif structure == 'three_sem': + created = _split(3, 'Semester') + elif structure == 'four_Quarter': + created = _split(4, 'Quarter') + elif structure == 'final_year': + created = [Term.create({ + 'name': f'{year.name} - Final Year', + 'term_start_date': str(start), + 'term_end_date': str(end), + 'academic_year_id': year.id, + })] + elif structure == 'two_sem_qua': + sems = _split(2, 'Semester') + quarters = [] + for parent_idx, parent in enumerate(sems): + s = parent.term_start_date + e = parent.term_end_date + span = max(1, (e - s).days) + half = span // 2 + q1 = Term.create({ + 'name': f'{year.name} - Q{parent_idx * 2 + 1}', + 'term_start_date': str(s), + 'term_end_date': str(s + timedelta(days=half)), + 'academic_year_id': year.id, + }) + q2 = Term.create({ + 'name': f'{year.name} - Q{parent_idx * 2 + 2}', + 'term_start_date': str(s + timedelta(days=half + 1)), + 'term_end_date': str(e), + 'academic_year_id': year.id, + }) + if 'parent_id' in Term._fields: + q1.write({'parent_id': parent.id}) + q2.write({'parent_id': parent.id}) + quarters += [q1, q2] + created = sems + quarters + else: + created = _split(2, 'Semester') + + year.invalidate_recordset() + return _json_response([_ser_term(t) for t in created]) + except Exception as e: + _logger.exception('generate_terms') + return _error_response(str(e), 500) + + # ── Academic Terms ─────────────────────────────────────────────── + + @http.route('/api/academic-terms', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_terms(self, **kw): + try: + M = request.env['op.academic.term'].sudo() + domain = [] + if kw.get('year_id'): + domain.append(('academic_year_id', '=', int(kw['year_id']))) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({'items': [_ser_term(r) for r in recs], 'data': [_ser_term(r) for r in recs], 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-terms', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_term(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'term_start_date': body.get('term_start_date'), + 'term_end_date': body.get('term_end_date'), + } + if body.get('academic_year_id'): + vals['academic_year_id'] = int(body['academic_year_id']) + rec = request.env['op.academic.term'].sudo().create(vals) + return _json_response(_ser_term(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-terms/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_term(self, tid, **kw): + try: + rec = request.env['op.academic.term'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'term_start_date', 'term_end_date'): + if k in body: + vals[k] = body[k] + if 'academic_year_id' in body: + vals['academic_year_id'] = int(body['academic_year_id']) + if vals: + rec.write(vals) + return _json_response(_ser_term(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-terms/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_term(self, tid, **kw): + try: + rec = request.env['op.academic.term'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Departments ────────────────────────────────────────────────── + + @http.route('/api/departments', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_depts(self, **kw): + try: + M = request.env['op.department'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + return _json_response({'items': [_ser_dept(r) for r in recs], 'data': [_ser_dept(r) for r in recs], 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/departments', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_dept(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + if body.get('parent_id'): + vals['parent_id'] = int(body['parent_id']) + rec = request.env['op.department'].sudo().create(vals) + return _json_response(_ser_dept(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/departments/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_dept(self, did, **kw): + try: + rec = request.env['op.department'].sudo().browse(did) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code'): + if k in body: + vals[k] = body[k] + if 'parent_id' in body: + vals['parent_id'] = int(body['parent_id']) if body['parent_id'] else False + if vals: + rec.write(vals) + return _json_response(_ser_dept(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/departments/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_dept(self, did, **kw): + try: + rec = request.env['op.department'].sudo().browse(did) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/activities.py b/backend/custom_addons/encoach_lms_api/controllers/activities.py new file mode 100644 index 00000000..4fb681f7 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/activities.py @@ -0,0 +1,117 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class ActivitiesController(http.Controller): + + @http.route('/api/activities', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_activities(self, **kw): + try: + M = request.env['op.activity'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if r.student_id else 0, + 'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '', + 'type_id': r.type_id.id if r.type_id else 0, + 'type_name': r.type_id.name if r.type_id else '', + 'date': str(r.date) if hasattr(r, 'date') and r.date else '', + 'description': getattr(r, 'description', '') or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activities', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_activity(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + if body.get('type_id'): + vals['type_id'] = int(body['type_id']) + if body.get('date'): + vals['date'] = body['date'] + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['op.activity'].sudo().create(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activities/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_activity(self, aid, **kw): + try: + rec = request.env['op.activity'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'description' in body: + vals['description'] = body['description'] + if 'date' in body: + vals['date'] = body['date'] + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activities/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_activity(self, aid, **kw): + try: + rec = request.env['op.activity'].sudo().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Activity Types ─────────────────────────────────────────────── + + @http.route('/api/activity-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_types(self, **kw): + try: + M = request.env['op.activity.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{'id': r.id, 'name': r.name or ''} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activity-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['op.activity.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activity-types/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_type(self, tid, **kw): + try: + rec = request.env['op.activity.type'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/admissions.py b/backend/custom_addons/encoach_lms_api/controllers/admissions.py new file mode 100644 index 00000000..cd5878a2 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/admissions.py @@ -0,0 +1,314 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_register(r): + return { + 'id': r.id, + 'name': r.name or '', + 'course_id': r.course_id.id if r.course_id else 0, + 'course_name': r.course_id.name if r.course_id else '', + 'start_date': str(r.start_date) if r.start_date else '', + 'end_date': str(r.end_date) if r.end_date else '', + 'min_count': r.min_count if hasattr(r, 'min_count') else 0, + 'max_count': r.max_count if hasattr(r, 'max_count') else 0, + 'application_count': len(r.admission_ids) if hasattr(r, 'admission_ids') else 0, + 'state': r.state or 'draft', + } + + +def _ser_admission(a): + nationality = getattr(a, 'nationality', False) + nationality_name = '' + nationality_id = None + if nationality: + nationality_id = nationality.id + nationality_name = nationality.name or '' + return { + 'id': a.id, + 'name': a.name or '', + 'application_number': getattr(a, 'application_number', '') or str(a.id), + 'register_id': a.register_id.id if hasattr(a, 'register_id') and a.register_id else 0, + 'register_name': a.register_id.name if hasattr(a, 'register_id') and a.register_id else '', + 'course_id': a.course_id.id if a.course_id else 0, + 'course_name': a.course_id.name if a.course_id else '', + 'batch_id': a.batch_id.id if hasattr(a, 'batch_id') and a.batch_id else None, + 'batch_name': a.batch_id.name if hasattr(a, 'batch_id') and a.batch_id else None, + 'first_name': getattr(a, 'first_name', '') or a.name or '', + 'middle_name': getattr(a, 'middle_name', '') or '', + 'last_name': getattr(a, 'last_name', '') or '', + 'email': getattr(a, 'email', '') or '', + 'phone': getattr(a, 'phone', '') or '', + 'birth_date': str(a.birth_date) if hasattr(a, 'birth_date') and a.birth_date else None, + 'gender': a.gender if hasattr(a, 'gender') else 'o', + 'nationality_id': nationality_id, + 'nationality_name': nationality_name, + 'state': a.state or 'draft', + 'fees': getattr(a, 'fees', 0) or 0, + 'student_id': a.student_id.id if hasattr(a, 'student_id') and a.student_id else None, + 'created_at': str(a.create_date) if a.create_date else '', + } + + +class AdmissionsController(http.Controller): + + # ── Admission Registers ────────────────────────────────────────── + + @http.route('/api/admission-registers', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_registers(self, **kw): + try: + M = request.env['op.admission.register'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [_ser_register(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_register(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('start_date'): + vals['start_date'] = body['start_date'] + if body.get('end_date'): + vals['end_date'] = body['end_date'] + if body.get('min_count'): + vals['min_count'] = int(body['min_count']) + if body.get('max_count'): + vals['max_count'] = int(body['max_count']) + rec = request.env['op.admission.register'].sudo().create(vals) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + for k in ('min_count', 'max_count', 'course_id'): + if k in body: + vals[k] = int(body[k]) + if vals: + rec.write(vals) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers//confirm', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def confirm_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'action_confirm'): + rec.action_confirm() + else: + rec.write({'state': 'confirm'}) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers//close', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def close_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'done'}) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Admissions ─────────────────────────────────────────────────── + + @http.route('/api/admissions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_admissions(self, **kw): + try: + M = request.env['op.admission'].sudo() + domain = [] + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + if kw.get('register_id'): + domain.append(('register_id', '=', int(kw['register_id']))) + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + q = (kw.get('q') or kw.get('search') or '').strip() + if q: + domain += ['|', '|', '|', '|', + ('name', 'ilike', q), + ('first_name', 'ilike', q), + ('last_name', 'ilike', q), + ('email', 'ilike', q), + ('application_number', 'ilike', q)] + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_admission(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_admission(self, **kw): + try: + from odoo import fields as odoo_fields + body = _get_json_body() + vals = { + 'name': f"{body.get('first_name', '')} {body.get('last_name', '')}".strip() + or body.get('name') or 'Applicant', + 'application_date': body.get('application_date') or odoo_fields.Date.today(), + } + if body.get('register_id'): + vals['register_id'] = int(body['register_id']) + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + for k in ('first_name', 'middle_name', 'last_name', 'email', 'phone', 'gender', 'birth_date'): + if body.get(k): + vals[k] = body[k] + if body.get('nationality_id') or body.get('nationality'): + vals['nationality'] = int(body.get('nationality_id') or body.get('nationality')) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + rec = request.env['op.admission'].sudo().create(vals) + return _json_response(_ser_admission(rec)) + except Exception as e: + _logger.exception('create_admission') + return _error_response(str(e), 500) + + @http.route('/api/admissions/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('first_name', 'middle_name', 'last_name', 'email', 'phone', + 'gender', 'birth_date', 'name'): + if k in body: + vals[k] = body[k] + for fk in ('register_id', 'course_id', 'batch_id'): + if fk in body and body[fk]: + vals[fk] = int(body[fk]) + if vals: + rec.write(vals) + return _json_response(_ser_admission(rec)) + except Exception as e: + _logger.exception('update_admission') + return _error_response(str(e), 500) + + @http.route('/api/admissions//submit', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def submit_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'submit_form'): + rec.submit_form() + else: + rec.write({'state': 'submit'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions//confirm', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def confirm_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'confirm_in_progress'): + rec.confirm_in_progress() + else: + rec.write({'state': 'confirm'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions//admit', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def admit(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'enroll_student'): + rec.enroll_student() + else: + rec.write({'state': 'admission'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions//reject', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def reject_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'reject'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/attendance.py b/backend/custom_addons/encoach_lms_api/controllers/attendance.py new file mode 100644 index 00000000..04cd43c9 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/attendance.py @@ -0,0 +1,52 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + + +def _ser_att(line): + sheet = line.attendance_id if hasattr(line, 'attendance_id') else None + student = line.student_id if hasattr(line, 'student_id') else None + return { + 'id': line.id, + 'date': str(sheet.attendance_date) if sheet and hasattr(sheet, 'attendance_date') and sheet.attendance_date else '', + 'course_id': sheet.register_id.course_id.id if sheet and hasattr(sheet, 'register_id') and sheet.register_id and sheet.register_id.course_id else 0, + 'course_name': sheet.register_id.course_id.name if sheet and hasattr(sheet, 'register_id') and sheet.register_id and sheet.register_id.course_id else '', + 'student_id': student.id if student else 0, + 'student_name': student.partner_id.name if student and student.partner_id else '', + 'status': line.present and 'present' or 'absent' if hasattr(line, 'present') else 'present', + } + + +class AttendanceController(http.Controller): + + @http.route('/api/attendance', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_attendance(self, **kw): + try: + M = request.env['op.attendance.line'].sudo() + domain = [] + if kw.get('student_id'): + domain.append(('student_id', '=', int(kw['student_id']))) + if kw.get('date'): + domain.append(('attendance_id.attendance_date', '=', kw['date'])) + recs = M.search(domain, limit=200, order='id desc') + data = [_ser_att(r) for r in recs] + return _json_response({'items': data, 'data': data, 'total': len(data)}) + except Exception as e: + _logger.exception('list_attendance') + return _error_response(str(e), 500) + + @http.route('/api/attendance', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def record_attendance(self, **kw): + try: + body = _get_json_body() + _logger.info('record_attendance body: %s', body) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/classrooms.py b/backend/custom_addons/encoach_lms_api/controllers/classrooms.py new file mode 100644 index 00000000..20cb1073 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/classrooms.py @@ -0,0 +1,96 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_classroom(r): + return { + 'id': r.id, + 'name': r.name or '', + 'code': getattr(r, 'code', '') or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'capacity': getattr(r, 'capacity', 0) or 0, + } + + +class ClassroomsController(http.Controller): + + @http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_groups(self, **kw): + try: + M = request.env['op.classroom'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [_ser_classroom(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_group(self, gid, **kw): + try: + rec = request.env['op.classroom'].sudo().browse(gid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_classroom(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_group(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + if body.get('capacity'): + vals['capacity'] = int(body['capacity']) + rec = request.env['op.classroom'].sudo().create(vals) + return _json_response(_ser_classroom(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_group(self, gid, **kw): + try: + rec = request.env['op.classroom'].sudo().browse(gid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def transfer(self, **kw): + try: + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups//members', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def add_members(self, gid, **kw): + try: + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups//members/remove', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def remove_members(self, gid, **kw): + try: + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/communication.py b/backend/custom_addons/encoach_lms_api/controllers/communication.py new file mode 100644 index 00000000..8db63eb6 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/communication.py @@ -0,0 +1,402 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_board(b): + return { + 'id': b.id, + 'name': b.name or '', + 'course_id': b.course_id.id if b.course_id else 0, + 'course_name': b.course_id.name if b.course_id else '', + 'batch_id': b.batch_id.id if b.batch_id else None, + 'batch_name': b.batch_id.name if b.batch_id else None, + 'chapter_id': b.chapter_id.id if b.chapter_id else None, + 'chapter_name': b.chapter_id.name if b.chapter_id else None, + 'is_enabled': b.is_enabled, + 'allow_student_posts': b.allow_student_posts, + 'post_count': b.post_count or 0, + 'assignment_id': None, + } + + +def _ser_post(p): + return { + 'id': p.id, + 'board_id': p.board_id.id, + 'parent_id': p.parent_id.id if p.parent_id else None, + 'author_id': p.author_id.id, + 'author_name': p.author_id.name or '', + 'author_role': '', + 'title': p.title or '', + 'content': p.content or '', + 'attachment_ids': [], + 'attachment_names': [], + 'is_pinned': p.is_pinned, + 'is_resolved': p.is_resolved, + 'reply_count': len(p.child_ids), + 'created_at': str(p.create_date) if p.create_date else '', + } + + +def _ser_announcement(a): + return { + 'id': a.id, + 'title': a.title or '', + 'content': a.content or '', + 'author_id': a.author_id.id, + 'author_name': a.author_id.name or '', + 'course_id': a.course_id.id if a.course_id else None, + 'course_name': a.course_id.name if a.course_id else None, + 'batch_id': a.batch_id.id if a.batch_id else None, + 'batch_name': a.batch_id.name if a.batch_id else None, + 'priority': a.priority or 'normal', + 'is_published': a.is_published, + 'published_at': str(a.published_at) if a.published_at else None, + 'expires_at': str(a.expires_at) if a.expires_at else None, + 'send_email': a.send_email, + 'attachment_ids': [], + 'attachment_names': [], + } + + +def _ser_message(m): + return { + 'id': m.id, + 'sender_id': m.sender_id.id, + 'sender_name': m.sender_id.name or '', + 'recipient_id': m.recipient_id.id, + 'recipient_name': m.recipient_id.name or '', + 'subject': m.subject or '', + 'content': m.content or '', + 'is_read': m.is_read, + 'read_at': str(m.read_at) if m.read_at else None, + 'attachment_ids': [], + 'attachment_names': [], + 'send_email_copy': m.send_email_copy, + 'created_at': str(m.create_date) if m.create_date else '', + } + + +class CommunicationController(http.Controller): + + # ── Discussion Boards ──────────────────────────────────────────── + + @http.route('/api/discussion-boards', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_boards(self, **kw): + try: + M = request.env['encoach.discussion.board'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + recs = M.search(domain, limit=200, order='id desc') + return _json_response([_ser_board(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/discussion-boards', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_board(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + rec = request.env['encoach.discussion.board'].sudo().create(vals) + return _json_response(_ser_board(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/discussion-boards/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_board(self, bid, **kw): + try: + rec = request.env['encoach.discussion.board'].sudo().browse(bid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'is_enabled' in body: + vals['is_enabled'] = bool(body['is_enabled']) + if 'allow_student_posts' in body: + vals['allow_student_posts'] = bool(body['allow_student_posts']) + if vals: + rec.write(vals) + return _json_response(_ser_board(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Posts ──────────────────────────────────────────────────────── + + @http.route('/api/discussion-boards//posts', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_posts(self, bid, **kw): + try: + M = request.env['encoach.discussion.post'].sudo() + domain = [('board_id', '=', bid), ('parent_id', '=', False)] + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='is_pinned desc, create_date desc') + items = [_ser_post(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/discussion-boards//posts', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_post(self, bid, **kw): + try: + body = _get_json_body() + vals = { + 'board_id': bid, + 'author_id': request.env.uid, + 'content': body.get('content', ''), + } + if body.get('title'): + vals['title'] = body['title'] + if body.get('parent_id'): + vals['parent_id'] = int(body['parent_id']) + rec = request.env['encoach.discussion.post'].sudo().create(vals) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'content' in body: + vals['content'] = body['content'] + if 'title' in body: + vals['title'] = body['title'] + if vals: + rec.write(vals) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts//pin', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def pin_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + pinned = body.get('pinned', not rec.is_pinned) + rec.write({'is_pinned': pinned}) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts//resolve', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def resolve_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'is_resolved': True}) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Announcements ──────────────────────────────────────────────── + + @http.route('/api/announcements', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_announcements(self, **kw): + try: + M = request.env['encoach.announcement'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('priority'): + domain.append(('priority', '=', kw['priority'])) + recs = M.search(domain, limit=200, order='create_date desc') + return _json_response([_ser_announcement(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_announcement(self, **kw): + try: + body = _get_json_body() + vals = { + 'title': body.get('title', ''), + 'content': body.get('content', ''), + 'author_id': request.env.uid, + 'priority': body.get('priority', 'normal'), + 'send_email': body.get('send_email', False), + } + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + if body.get('expires_at'): + vals['expires_at'] = body['expires_at'] + rec = request.env['encoach.announcement'].sudo().create(vals) + return _json_response(_ser_announcement(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_announcement(self, aid, **kw): + try: + rec = request.env['encoach.announcement'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('title', 'content', 'priority', 'expires_at'): + if k in body: + vals[k] = body[k] + if 'send_email' in body: + vals['send_email'] = bool(body['send_email']) + if vals: + rec.write(vals) + return _json_response(_ser_announcement(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_announcement(self, aid, **kw): + try: + rec = request.env['encoach.announcement'].sudo().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements//publish', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def publish_announcement(self, aid, **kw): + try: + rec = request.env['encoach.announcement'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import datetime + rec.write({'is_published': True, 'published_at': str(datetime.now())}) + return _json_response(_ser_announcement(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Messages ───────────────────────────────────────────────────── + + @http.route('/api/messages', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_messages(self, **kw): + try: + M = request.env['encoach.message'].sudo() + uid = request.env.uid + domain = [('recipient_id', '=', uid)] + if kw.get('is_read') == 'false': + domain.append(('is_read', '=', False)) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='create_date desc') + items = [_ser_message(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/sent', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_sent_messages(self, **kw): + try: + M = request.env['encoach.message'].sudo() + uid = request.env.uid + offset, limit, page = _paginate(kw) + domain = [('sender_id', '=', uid)] + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='create_date desc') + items = [_ser_message(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def send_message(self, **kw): + try: + body = _get_json_body() + vals = { + 'sender_id': request.env.uid, + 'recipient_id': int(body.get('recipient_id', 0)), + 'subject': body.get('subject', ''), + 'content': body.get('content', ''), + 'send_email_copy': body.get('send_email_copy', False), + } + rec = request.env['encoach.message'].sudo().create(vals) + return _json_response(_ser_message(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_message(self, mid, **kw): + try: + rec = request.env['encoach.message'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + if not rec.is_read and rec.recipient_id.id == request.env.uid: + from datetime import datetime + rec.write({'is_read': True, 'read_at': str(datetime.now())}) + return _json_response(_ser_message(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_message(self, mid, **kw): + try: + rec = request.env['encoach.message'].sudo().browse(mid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/unread-count', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def unread_count(self, **kw): + try: + count = request.env['encoach.message'].sudo().search_count([ + ('recipient_id', '=', request.env.uid), + ('is_read', '=', False), + ]) + return _json_response({'count': count}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/courseware.py b/backend/custom_addons/encoach_lms_api/controllers/courseware.py new file mode 100644 index 00000000..41f39c02 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/courseware.py @@ -0,0 +1,613 @@ +import logging +import base64 +from odoo import http, fields +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_chapter(c): + objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective']) + return { + 'id': c.id, + 'name': c.name or '', + 'course_id': c.course_id.id if c.course_id else 0, + 'course_name': c.course_id.name if c.course_id else '', + 'sequence': c.sequence, + 'description': c.description or '', + 'start_date': str(c.start_date) if c.start_date else None, + 'end_date': str(c.end_date) if c.end_date else None, + 'unlock_mode': c.unlock_mode or 'manual', + 'is_unlocked': c.is_unlocked, + 'topic_id': c.topic_id.id if c.topic_id else None, + 'topic_name': c.topic_id.name if c.topic_id else None, + 'domain_id': c.domain_id.id if c.domain_id else None, + 'domain_name': c.domain_id.name if c.domain_id else None, + 'learning_objective_ids': objectives.ids, + 'learning_objective_names': objectives.mapped('name'), + 'material_count': c.material_count or 0, + 'assignment_ids': [], + 'exam_ids': [], + } + + +def _ser_material(m): + return { + 'id': m.id, + 'name': m.name or '', + 'chapter_id': m.chapter_id.id, + 'type': m.type or 'pdf', + 'file_url': f'/api/materials/{m.id}/download' if m.file_data else None, + 'url': m.url or None, + 'description': m.description or None, + 'sequence': m.sequence, + 'allow_download': m.allow_download, + 'is_book': m.is_book, + 'book_chapters': m.book_chapters or None, + } + + +class CoursewareController(http.Controller): + + # ── Global Material Search ──────────────────────────────────────── + + @http.route('/api/materials/search', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def search_materials(self, **kw): + """Search all materials across all courses. Used by Admin Resources page.""" + try: + M = request.env['encoach.chapter.material'].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('type') and kw['type'] != 'all': + domain.append(('type', '=', kw['type'])) + if kw.get('course_id'): + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', int(kw['course_id'])) + ]) + domain.append(('chapter_id', 'in', chapters.ids)) + total = M.search_count(domain) + limit = min(int(kw.get('limit', 50)), 200) + offset = int(kw.get('offset', 0)) + recs = M.search(domain, limit=limit, offset=offset, order='id desc') + items = [] + for m in recs: + d = _ser_material(m) + d['course_id'] = m.chapter_id.course_id.id if m.chapter_id.course_id else 0 + d['course_name'] = m.chapter_id.course_id.name if m.chapter_id.course_id else '' + d['chapter_name'] = m.chapter_id.name if m.chapter_id else '' + d['source'] = 'course_material' + items.append(d) + return _json_response({'items': items, 'total': total}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Chapters ───────────────────────────────────────────────────── + + @http.route('/api/courses//chapters', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_chapters(self, course_id, **kw): + try: + M = request.env['encoach.course.chapter'].sudo() + recs = M.search([('course_id', '=', course_id)], order='sequence, id') + return _json_response([_ser_chapter(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/courses//chapters', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_chapter(self, course_id, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'course_id': course_id, + 'sequence': body.get('sequence', 10), + 'description': body.get('description', ''), + 'unlock_mode': body.get('unlock_mode', 'manual'), + } + if body.get('start_date'): + vals['start_date'] = body['start_date'] + if body.get('end_date'): + vals['end_date'] = body['end_date'] + if body.get('topic_id'): + vals['topic_id'] = int(body['topic_id']) + if body.get('learning_objective_ids'): + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + rec = request.env['encoach.course.chapter'].sudo().create(vals) + return _json_response(_ser_chapter(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_chapter(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'description', 'unlock_mode', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'topic_id' in body: + vals['topic_id'] = int(body['topic_id']) if body['topic_id'] else False + if 'is_unlocked' in body: + vals['is_unlocked'] = bool(body['is_unlocked']) + if 'learning_objective_ids' in body: + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + if vals: + rec.write(vals) + return _json_response(_ser_chapter(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//unlock', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def unlock_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'is_unlocked': True}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//lock', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def lock_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'is_unlocked': False}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/courses//chapters/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def reorder_chapters(self, course_id, **kw): + try: + body = _get_json_body() + ids = body.get('ids', []) + for seq, cid in enumerate(ids): + rec = request.env['encoach.course.chapter'].sudo().browse(int(cid)) + if rec.exists(): + rec.write({'sequence': seq * 10}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//progress', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_chapter_progress(self, cid, **kw): + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if student: + prog = request.env['encoach.chapter.progress'].sudo().search([ + ('chapter_id', '=', cid), ('student_id', '=', student.id) + ], limit=1) + if prog: + return _json_response({ + 'id': prog.id, + 'student_id': student.id, + 'student_name': student.partner_id.name or '', + 'chapter_id': cid, + 'status': prog.status, + 'started_at': str(prog.started_at) if prog.started_at else None, + 'completed_at': str(prog.completed_at) if prog.completed_at else None, + 'materials_completed': prog.materials_completed, + 'materials_total': prog.materials_total, + }) + return _json_response({ + 'id': 0, 'student_id': 0, 'student_name': '', 'chapter_id': cid, + 'status': 'not_started', 'started_at': None, 'completed_at': None, + 'materials_completed': 0, 'materials_total': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//progress/complete', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_chapter_complete(self, cid, **kw): + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _error_response('Student not found for current user', 404) + chapter = request.env['encoach.course.chapter'].sudo().browse(cid) + if not chapter.exists(): + return _error_response('Chapter not found', 404) + + Progress = request.env['encoach.chapter.progress'].sudo() + prog = Progress.search([ + ('chapter_id', '=', cid), + ('student_id', '=', student.id), + ], limit=1) + now = fields.Datetime.now() + if prog: + prog.write({ + 'status': 'completed', + 'completed_at': now, + 'materials_completed': chapter.material_count, + 'materials_total': chapter.material_count, + }) + else: + prog = Progress.create({ + 'chapter_id': cid, + 'student_id': student.id, + 'status': 'completed', + 'started_at': now, + 'completed_at': now, + 'materials_completed': chapter.material_count, + 'materials_total': chapter.material_count, + }) + + course_id = chapter.course_id.id + self._check_course_completion(student.id, course_id) + + return _json_response({ + 'success': True, + 'chapter_id': cid, + 'status': 'completed', + }) + except Exception as e: + _logger.exception('mark_chapter_complete failed') + return _error_response(str(e), 500) + + def _check_course_completion(self, student_id, course_id): + """Check if all chapters are completed; if so, mark course as completed and enable post-test.""" + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', course_id) + ]) + if not chapters: + return + completed = request.env['encoach.chapter.progress'].sudo().search_count([ + ('student_id', '=', student_id), + ('chapter_id', 'in', chapters.ids), + ('status', '=', 'completed'), + ]) + total = len(chapters) + progress_pct = int((completed / total * 100) if total else 0) + Completion = request.env['encoach.course.completion'].sudo() + comp = Completion.search([ + ('student_id', '=', student_id), + ('course_id', '=', course_id), + ], limit=1) + vals = { + 'chapters_total': total, + 'chapters_completed': completed, + 'progress_percent': progress_pct, + } + if completed >= total: + vals['status'] = 'completed' + vals['completed_at'] = fields.Datetime.now() + vals['post_test_available'] = True + else: + vals['status'] = 'in_progress' + vals['post_test_available'] = False + if comp: + comp.write(vals) + else: + vals['student_id'] = student_id + vals['course_id'] = course_id + Completion.create(vals) + + @http.route('/api/courses//completion', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_course_completion(self, course_id, **kw): + """Get course completion status for the current student.""" + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _json_response({ + 'course_id': course_id, + 'status': 'not_enrolled', + 'chapters_total': 0, + 'chapters_completed': 0, + 'progress_percent': 0, + 'post_test_available': False, + }) + comp = request.env['encoach.course.completion'].sudo().search([ + ('student_id', '=', student.id), + ('course_id', '=', course_id), + ], limit=1) + if comp: + return _json_response({ + 'course_id': course_id, + 'status': comp.status, + 'chapters_total': comp.chapters_total, + 'chapters_completed': comp.chapters_completed, + 'progress_percent': comp.progress_percent, + 'completed_at': str(comp.completed_at) if comp.completed_at else None, + 'post_test_available': comp.post_test_available, + }) + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', course_id) + ]) + completed = request.env['encoach.chapter.progress'].sudo().search_count([ + ('student_id', '=', student.id), + ('chapter_id', 'in', chapters.ids), + ('status', '=', 'completed'), + ]) + total = len(chapters) + return _json_response({ + 'course_id': course_id, + 'status': 'in_progress' if completed > 0 else 'not_started', + 'chapters_total': total, + 'chapters_completed': completed, + 'progress_percent': int((completed / total * 100) if total else 0), + 'post_test_available': completed >= total and total > 0, + }) + except Exception as e: + _logger.exception('get_course_completion failed') + return _error_response(str(e), 500) + + # ── Materials ──────────────────────────────────────────────────── + + @http.route('/api/chapters//materials', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_materials(self, cid, **kw): + try: + M = request.env['encoach.chapter.material'].sudo() + recs = M.search([('chapter_id', '=', cid)], order='sequence, id') + return _json_response([_ser_material(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//materials', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def upload_material(self, cid, **kw): + try: + files = request.httprequest.files + f = files.get('file') + body = {} + ct = request.httprequest.content_type or '' + if 'application/json' in ct: + body = _get_json_body() + params = {**body, **kw} + vals = { + 'chapter_id': cid, + 'name': params.get('name', f.filename if f else 'Material'), + 'type': params.get('type', 'pdf'), + 'sequence': int(params.get('sequence', 10)), + 'allow_download': str(params.get('allow_download', 'true')).lower() == 'true', + } + if f: + vals['file_data'] = base64.b64encode(f.read()) + vals['file_name'] = f.filename + if params.get('url'): + vals['url'] = params['url'] + if params.get('description'): + vals['description'] = params['description'] + rec = request.env['encoach.chapter.material'].sudo().create(vals) + return _json_response(_ser_material(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//materials/from-resource', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_material_from_resource(self, cid, **kw): + """Create a chapter material by copying from an existing library resource.""" + try: + body = _get_json_body() + resource_id = body.get('resource_id') + if not resource_id: + return _error_response('resource_id is required', 400) + res = request.env['encoach.resource'].sudo().browse(int(resource_id)) + if not res.exists(): + return _error_response('Resource not found', 404) + type_map = { + 'pdf': 'pdf', 'document': 'document', 'video': 'video', + 'link': 'link', 'interactive': 'document', + } + vals = { + 'chapter_id': cid, + 'name': body.get('name') or res.name, + 'type': type_map.get(res.type, 'document'), + 'sequence': int(body.get('sequence', 10)), + 'allow_download': True, + 'url': res.url or '', + 'resource_id': res.id, + } + if res.file: + vals['file_data'] = res.file + vals['file_name'] = res.name + rec = request.env['encoach.chapter.material'].sudo().create(vals) + return _json_response(_ser_material(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_material(self, mid, **kw): + try: + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'type', 'url', 'description'): + if k in body: + vals[k] = body[k] + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'allow_download' in body: + vals['allow_download'] = bool(body['allow_download']) + if vals: + rec.write(vals) + return _json_response(_ser_material(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_material(self, mid, **kw): + try: + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials//download', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def download_material(self, mid, **kw): + try: + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if not rec.exists() or not rec.file_data: + return _error_response('Not found', 404) + data = base64.b64decode(rec.file_data) + fname = rec.file_name or 'download' + return request.make_response(data, headers=[ + ('Content-Type', 'application/octet-stream'), + ('Content-Disposition', f'attachment; filename="{fname}"'), + ]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials//content', type='http', auth='public', methods=['GET'], csrf=False) + def material_content(self, mid, **kw): + """Serve material file inline (for iframe/embed) instead of as download. + Accepts JWT via Authorization header OR ?token= query param (for iframe src).""" + try: + from odoo.addons.encoach_api.controllers.base import validate_token, _get_jwt_secret + import jwt as pyjwt + user = validate_token() + if not user: + token_param = kw.get('token') or request.httprequest.args.get('token') + if token_param: + secret = _get_jwt_secret() + if secret: + try: + payload = pyjwt.decode(token_param, secret, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + user = request.env['res.users'].sudo().browse(int(uid)) + if user.exists(): + request.update_env(user=user.id) + except Exception: + pass + if not user: + return _error_response("Authentication required", 401) + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if not rec.exists() or not rec.file_data: + return _error_response('Not found', 404) + data = base64.b64decode(rec.file_data) + fname = rec.file_name or 'file' + ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else '' + mime_map = { + 'pdf': 'application/pdf', + 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg', + 'mp3': 'audio/mpeg', 'wav': 'audio/wav', + 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', + 'gif': 'image/gif', 'svg': 'image/svg+xml', 'webp': 'image/webp', + 'html': 'text/html', 'htm': 'text/html', + 'txt': 'text/plain', + } + content_type = mime_map.get(ext, 'application/octet-stream') + return request.make_response(data, headers=[ + ('Content-Type', content_type), + ('Content-Disposition', f'inline; filename="{fname}"'), + ('Cache-Control', 'public, max-age=3600'), + ]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials//viewed', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_viewed(self, mid, **kw): + """Mark a material as viewed and update chapter progress.""" + try: + material = request.env['encoach.chapter.material'].sudo().browse(mid) + if not material.exists(): + return _error_response('Not found', 404) + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _json_response({'success': True}) + chapter = material.chapter_id + Progress = request.env['encoach.chapter.progress'].sudo() + prog = Progress.search([ + ('chapter_id', '=', chapter.id), + ('student_id', '=', student.id), + ], limit=1) + now = fields.Datetime.now() + if prog: + new_count = min(prog.materials_completed + 1, chapter.material_count) + vals = { + 'materials_completed': new_count, + 'materials_total': chapter.material_count, + } + if prog.status == 'not_started': + vals['status'] = 'in_progress' + vals['started_at'] = now + if new_count >= chapter.material_count: + vals['status'] = 'completed' + vals['completed_at'] = now + prog.write(vals) + else: + status = 'completed' if chapter.material_count <= 1 else 'in_progress' + prog = Progress.create({ + 'chapter_id': chapter.id, + 'student_id': student.id, + 'status': status, + 'started_at': now, + 'completed_at': now if status == 'completed' else False, + 'materials_completed': 1, + 'materials_total': chapter.material_count, + }) + if prog.status == 'completed': + self._check_course_completion(student.id, chapter.course_id.id) + return _json_response({'success': True, 'materials_completed': prog.materials_completed}) + except Exception as e: + _logger.exception('mark_viewed failed') + return _error_response(str(e), 500) + + @http.route('/api/chapters//materials/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def reorder_materials(self, cid, **kw): + try: + body = _get_json_body() + ids = body.get('ids', []) + for seq, mid in enumerate(ids): + rec = request.env['encoach.chapter.material'].sudo().browse(int(mid)) + if rec.exists(): + rec.write({'sequence': seq * 10}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/facilities.py b/backend/custom_addons/encoach_lms_api/controllers/facilities.py new file mode 100644 index 00000000..cfb647e1 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/facilities.py @@ -0,0 +1,161 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class FacilitiesController(http.Controller): + + @http.route('/api/facilities', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_facilities(self, **kw): + try: + M = request.env['op.facility'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'code': getattr(r, 'code', '') or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/facilities', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_facility(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + rec = request.env['op.facility'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name, 'code': getattr(rec, 'code', '')}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/facilities/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_facility(self, fid, **kw): + try: + rec = request.env['op.facility'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'code' in body: + vals['code'] = body['code'] + if vals: + rec.write(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/facilities/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_facility(self, fid, **kw): + try: + rec = request.env['op.facility'].sudo().browse(fid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Assets ─────────────────────────────────────────────────────── + + # Assets are tracked via encoach.asset (lightweight custom model since + # OpenEduCat has no dedicated asset model in this build). + + def _asset_model(self): + return request.env['encoach.asset'].sudo() + + @http.route('/api/assets', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_assets(self, **kw): + try: + M = self._asset_model() + offset, limit, page = _paginate(kw) + domain = [] + if kw.get('facility_id'): + try: + domain.append(('facility_id', '=', int(kw['facility_id']))) + except Exception: + pass + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'code': r.code or '', + 'facility_id': r.facility_id.id if r.facility_id else 0, + 'facility_name': r.facility_id.name if r.facility_id else '', + 'description': r.description or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_assets') + return _error_response(str(e), 500) + + @http.route('/api/assets', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_asset(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('Name is required', 400) + vals = {'name': name} + if body.get('code'): + vals['code'] = body['code'] + if body.get('facility_id'): + vals['facility_id'] = int(body['facility_id']) + if body.get('description'): + vals['description'] = body['description'] + rec = self._asset_model().create(vals) + return _json_response({ + 'id': rec.id, 'name': rec.name, 'code': rec.code or '', + 'facility_id': rec.facility_id.id if rec.facility_id else 0, + }) + except Exception as e: + _logger.exception('create_asset') + return _error_response(str(e), 500) + + @http.route('/api/assets/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_asset(self, aid, **kw): + try: + rec = self._asset_model().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'description'): + if k in body: + vals[k] = body[k] + if 'facility_id' in body: + vals['facility_id'] = int(body['facility_id']) if body['facility_id'] else False + if vals: + rec.write(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/assets/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_asset(self, aid, **kw): + try: + rec = self._asset_model().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/faq.py b/backend/custom_addons/encoach_lms_api/controllers/faq.py new file mode 100644 index 00000000..1dcd7412 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/faq.py @@ -0,0 +1,174 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + + +def _ser_cat(c): + return { + 'id': c.id, + 'name': c.name or '', + 'sequence': c.sequence, + 'audience': c.audience or 'all', + 'is_published': c.is_published, + 'item_count': c.item_count or 0, + } + + +def _ser_item(i): + return { + 'id': i.id, + 'category_id': i.category_id.id, + 'category_name': i.category_id.name or '', + 'question': i.question or '', + 'answer': i.answer or '', + 'sequence': i.sequence, + 'audience': i.audience or 'all', + 'is_published': i.is_published, + 'video_url': i.video_url or '', + } + + +class FaqController(http.Controller): + + @http.route('/api/faq/categories', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_categories(self, **kw): + try: + M = request.env['encoach.faq.category'].sudo() + domain = [] + if kw.get('audience'): + domain.append(('audience', 'in', [kw['audience'], 'all'])) + recs = M.search(domain, order='sequence, id') + return _json_response([_ser_cat(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/categories', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_category(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('sequence') is not None: + vals['sequence'] = int(body['sequence']) + if body.get('audience'): + vals['audience'] = body['audience'] + rec = request.env['encoach.faq.category'].sudo().create(vals) + return _json_response(_ser_cat(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/categories/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_category(self, cid, **kw): + try: + rec = request.env['encoach.faq.category'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'audience'): + if k in body: + vals[k] = body[k] + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'is_published' in body: + vals['is_published'] = bool(body['is_published']) + if vals: + rec.write(vals) + return _json_response(_ser_cat(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/categories/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_category(self, cid, **kw): + try: + rec = request.env['encoach.faq.category'].sudo().browse(cid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Items ──────────────────────────────────────────────────────── + + @http.route('/api/faq/items', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_items(self, **kw): + try: + M = request.env['encoach.faq.item'].sudo() + domain = [] + if kw.get('category_id'): + domain.append(('category_id', '=', int(kw['category_id']))) + if kw.get('audience'): + domain.append(('audience', 'in', [kw['audience'], 'all'])) + if kw.get('search'): + domain.append(('question', 'ilike', kw['search'])) + recs = M.search(domain, order='sequence, id') + return _json_response([_ser_item(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/items', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_item(self, **kw): + try: + body = _get_json_body() + vals = { + 'question': body.get('question', ''), + 'answer': body.get('answer', ''), + 'category_id': int(body.get('category_id', 0)), + } + if body.get('sequence') is not None: + vals['sequence'] = int(body['sequence']) + if body.get('audience'): + vals['audience'] = body['audience'] + if body.get('video_url') is not None: + vals['video_url'] = body['video_url'] or False + rec = request.env['encoach.faq.item'].sudo().create(vals) + return _json_response(_ser_item(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/items/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_item(self, iid, **kw): + try: + rec = request.env['encoach.faq.item'].sudo().browse(iid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('question', 'answer', 'audience'): + if k in body: + vals[k] = body[k] + if 'category_id' in body: + vals['category_id'] = int(body['category_id']) + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'is_published' in body: + vals['is_published'] = bool(body['is_published']) + if 'video_url' in body: + vals['video_url'] = body['video_url'] or False + if vals: + rec.write(vals) + return _json_response(_ser_item(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/items/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_item(self, iid, **kw): + try: + rec = request.env['encoach.faq.item'].sudo().browse(iid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/fees.py b/backend/custom_addons/encoach_lms_api/controllers/fees.py new file mode 100644 index 00000000..baf6e348 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/fees.py @@ -0,0 +1,260 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _paginate, _get_json_body, +) + +_get_json = _get_json_body + +_logger = logging.getLogger(__name__) + + +def _serialize_plan(r): + """Build a fees-plan record with REAL paid/remaining pulled from the + linked account.move (invoice), not a hard-coded zero.""" + total = float(getattr(r, 'amount', 0) or 0) + after_discount = float(getattr(r, 'after_discount_amount', 0) or total) + invoice = r.invoice_id if hasattr(r, 'invoice_id') else False + + invoice_state = '' + payment_state = 'not_paid' + amount_residual = after_discount + paid_amount = 0.0 + invoice_id = 0 + invoice_number = '' + + if invoice and invoice.exists(): + invoice_id = invoice.id + invoice_state = invoice.state or '' + payment_state = getattr(invoice, 'payment_state', '') or 'not_paid' + amount_residual = float(getattr(invoice, 'amount_residual', after_discount) or 0) + amount_total = float(getattr(invoice, 'amount_total', after_discount) or 0) + paid_amount = max(0.0, amount_total - amount_residual) + invoice_number = getattr(invoice, 'name', '') or '' + + return { + 'id': r.id, + 'student_id': r.student_id.id if getattr(r, 'student_id', False) else 0, + 'student_name': ( + r.student_id.partner_id.name + if getattr(r, 'student_id', False) and r.student_id.partner_id else '' + ), + 'course_id': r.course_id.id if getattr(r, 'course_id', False) else 0, + 'course_name': r.course_id.name if getattr(r, 'course_id', False) else '', + 'batch_id': r.batch_id.id if getattr(r, 'batch_id', False) else 0, + 'batch_name': r.batch_id.name if getattr(r, 'batch_id', False) else '', + 'product_id': r.product_id.id if getattr(r, 'product_id', False) else 0, + 'product_name': r.product_id.name if getattr(r, 'product_id', False) else '', + 'date': str(r.date) if getattr(r, 'date', False) else '', + 'discount': float(getattr(r, 'discount', 0) or 0), + 'total_amount': total, + 'after_discount_amount': after_discount, + 'paid_amount': round(paid_amount, 2), + 'remaining_amount': round(amount_residual, 2), + 'state': getattr(r, 'state', 'draft') or 'draft', + 'invoice_id': invoice_id, + 'invoice_number': invoice_number, + 'invoice_state': invoice_state, + 'payment_state': payment_state, + 'currency': r.currency_id.name if getattr(r, 'currency_id', False) else '', + } + + +class FeesController(http.Controller): + + @http.route('/api/fees-plans', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_fees_plans(self, **kw): + try: + M = request.env['op.student.fees.details'].sudo() + offset, limit, page = _paginate(kw) + domain = [] + state = (kw.get('state') or '').strip() + if state: + domain.append(('state', '=', state)) + payment_state = (kw.get('payment_state') or '').strip() + q = (kw.get('q') or '').strip() + if q: + domain += ['|', + ('student_id.partner_id.name', 'ilike', q), + ('product_id.name', 'ilike', q)] + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_serialize_plan(r) for r in recs] + if payment_state: + items = [i for i in items if i['payment_state'] == payment_state] + return _json_response({ + 'items': items, 'data': items, + 'total': total, 'page': page, 'size': limit, + }) + except Exception as e: + _logger.exception('list_fees_plans') + return _error_response(str(e), 500) + + @http.route('/api/fees-plans/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_fees_plan(self, fid, **kw): + try: + rec = request.env['op.student.fees.details'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_serialize_plan(rec)) + except Exception as e: + _logger.exception('get_fees_plan') + return _error_response(str(e), 500) + + @http.route('/api/fees-plans//create-invoice', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_invoice(self, fid, **kw): + """Generate the accounting invoice for this fee line (uses the built-in + OpenEduCat `get_invoice()` model method).""" + try: + rec = request.env['op.student.fees.details'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Fees plan not found', 404) + if not rec.product_id: + return _error_response('This fees plan has no product; cannot invoice.', 400) + if float(rec.amount or 0) <= 0: + return _error_response('Fees amount must be positive to invoice.', 400) + + # Auto-wire a default income account on the product/category if + # the Odoo chart of accounts is set up but the product was seeded + # without one. Otherwise OpenEduCat raises a UserError. + prod_tmpl = rec.product_id.sudo().product_tmpl_id + try: + fp = prod_tmpl.get_product_accounts() if hasattr( + prod_tmpl, 'get_product_accounts') else {} + except Exception: + fp = {} + if not (fp or {}).get('income'): + AA = request.env['account.account'].sudo() + # Odoo 19: account.account has a M2M `company_ids`. + try: + income_acc = AA.search([ + ('account_type', '=', 'income'), + ('company_ids', 'in', request.env.company.id), + ], limit=1) + except Exception: + income_acc = AA.browse() + if not income_acc: + income_acc = AA.search([('account_type', '=', 'income')], limit=1) + if income_acc: + try: + prod_tmpl.with_company(request.env.company).write({ + 'property_account_income_id': income_acc.id, + }) + except Exception: + _logger.exception('auto-wire income account') + rec.get_invoice() + rec.invalidate_recordset() + return _json_response(_serialize_plan(rec)) + except Exception as e: + _logger.exception('create_invoice') + return _error_response(str(e), 500) + + @http.route('/api/fees-plans//register-payment', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def register_payment(self, fid, **kw): + """Register a payment against the invoice linked to this fees plan. + Body: { "amount": float, "journal_id": int (optional), + "payment_date": "YYYY-MM-DD" (optional), "memo": str (optional) }""" + try: + body = _get_json() or {} + rec = request.env['op.student.fees.details'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Fees plan not found', 404) + invoice = rec.invoice_id + if not invoice or not invoice.exists(): + return _error_response('Create an invoice before registering a payment.', 400) + if invoice.state == 'draft': + invoice.sudo().action_post() + + amount = float(body.get('amount') or invoice.amount_residual or 0) + if amount <= 0: + return _error_response('Payment amount must be positive.', 400) + + payment_date = body.get('payment_date') or False + memo = body.get('memo') or f"Payment for {rec.product_id.name or 'fees'}" + + journal_id = body.get('journal_id') + if not journal_id: + journal = request.env['account.journal'].sudo().search( + [('type', 'in', ('bank', 'cash'))], limit=1 + ) + if not journal: + return _error_response( + 'No bank/cash journal configured. Open Accounting → Configuration → Journals.', + 400) + journal_id = journal.id + + wiz_vals = { + 'amount': amount, + 'journal_id': int(journal_id), + 'communication': memo, + } + if payment_date: + wiz_vals['payment_date'] = payment_date + wizard = request.env['account.payment.register'].sudo().with_context( + active_model='account.move', active_ids=[invoice.id] + ).create(wiz_vals) + wizard.action_create_payments() + invoice.invalidate_recordset() + rec.invalidate_recordset() + return _json_response(_serialize_plan(rec)) + except Exception as e: + _logger.exception('register_payment') + return _error_response(str(e), 500) + + @http.route('/api/student-fees', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_student_fees(self, **kw): + try: + M = request.env['op.student.fees.details'].sudo() + offset, limit, page = _paginate(kw) + domain = [] + student_id = kw.get('student_id') + if student_id: + try: + domain.append(('student_id', '=', int(student_id))) + except Exception: + pass + q = (kw.get('q') or '').strip() + if q: + domain += ['|', + ('student_id.partner_id.name', 'ilike', q), + ('product_id.name', 'ilike', q)] + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_serialize_plan(r) for r in recs] + return _json_response({ + 'items': items, 'data': items, + 'total': total, 'page': page, 'size': limit, + }) + except Exception as e: + _logger.exception('list_student_fees') + return _error_response(str(e), 500) + + @http.route('/api/fees-terms', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_fees_terms(self, **kw): + try: + M = request.env['op.fees.terms'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'no_days': getattr(r, 'no_days', 0) or 0, + 'line_ids': r.line_ids.ids if hasattr(r, 'line_ids') else [], + } for r in recs] + return _json_response({ + 'items': items, 'data': items, + 'total': total, 'page': page, 'size': limit, + }) + except Exception as e: + _logger.exception('list_fees_terms') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/grades.py b/backend/custom_addons/encoach_lms_api/controllers/grades.py new file mode 100644 index 00000000..293de817 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/grades.py @@ -0,0 +1,201 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class GradesController(http.Controller): + + # ── Grades (simple list) ───────────────────────────────────────── + + @http.route('/api/grades', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_grades(self, **kw): + try: + M = request.env['encoach.gradebook.line'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + data = [] + for r in recs: + gb = r.gradebook_id + data.append({ + 'id': r.id, + 'course_id': gb.course_id.id if gb.course_id else 0, + 'course_name': gb.course_id.name if gb.course_id else '', + 'student_id': gb.student_id.id if gb.student_id else 0, + 'student_name': gb.student_id.partner_id.name if gb.student_id and gb.student_id.partner_id else '', + 'assignment_title': r.assignment_name or '', + 'grade': r.marks, + 'max_grade': 100, + 'date': str(r.create_date.date()) if r.create_date else '', + 'type': r.state or 'graded', + }) + return _json_response({'items': data, 'data': data, 'total': len(data)}) + except Exception as e: + _logger.exception('list_grades') + return _error_response(str(e), 500) + + # ── Gradebooks ─────────────────────────────────────────────────── + + @http.route('/api/gradebooks', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_gradebooks(self, **kw): + try: + M = request.env['encoach.gradebook'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if r.student_id else 0, + 'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '', + 'course_id': r.course_id.id if r.course_id else 0, + 'course_name': r.course_id.name if r.course_id else '', + 'academic_year_id': r.academic_year_id.id if r.academic_year_id else 0, + 'academic_year_name': r.academic_year_id.name if r.academic_year_id else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Gradebook Lines ────────────────────────────────────────────── + + @http.route('/api/gradebook-lines', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_gradebook_lines(self, **kw): + try: + M = request.env['encoach.gradebook.line'].sudo() + offset, limit, page = _paginate(kw) + domain = [] + gb_id = kw.get('gradebook_id') + if gb_id: + try: + domain.append(('gradebook_id', '=', int(gb_id))) + except Exception: + pass + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'gradebook_id': r.gradebook_id.id, + 'student_name': r.gradebook_id.student_id.partner_id.name if r.gradebook_id.student_id and r.gradebook_id.student_id.partner_id else '', + 'assignment_name': r.assignment_name or '', + 'marks': r.marks, + 'percentage': r.percentage, + 'state': r.state or 'draft', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Grading Assignments (using openeducat grading.assignment) ──── + + @http.route('/api/grading-assignments', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_grading_assignments(self, **kw): + try: + M = request.env['grading.assignment'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'sequence': getattr(r, 'sequence', '') or '', + 'name': r.name or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'subject_id': r.subject_id.id if hasattr(r, 'subject_id') and r.subject_id else 0, + 'subject_name': r.subject_id.name if hasattr(r, 'subject_id') and r.subject_id else '', + 'state': getattr(r, 'state', 'draft') or 'draft', + 'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/grading-assignments', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_grading_assignment(self, **kw): + try: + from odoo import fields as odoo_fields + body = _get_json_body() + + # assignment_type is required on grading.assignment. Find or create a default. + AType = request.env['grading.assignment.type'].sudo() + atype_id = body.get('assignment_type') or body.get('assignment_type_id') + if atype_id: + atype_id = int(atype_id) + else: + atype = AType.search([('code', '=', 'DEFAULT')], limit=1) + if not atype: + atype = AType.create({'name': 'Default', 'code': 'DEFAULT'}) + atype_id = atype.id + + # faculty_id is required. Prefer body, otherwise first available. + Faculty = request.env['op.faculty'].sudo() + faculty_id = body.get('faculty_id') + if faculty_id: + faculty_id = int(faculty_id) + else: + fac = Faculty.search([], limit=1) + if fac: + faculty_id = fac.id + + if not faculty_id: + return _error_response('A faculty is required but none exist in the system.', 400) + + vals = { + 'name': body.get('name') or 'Assignment', + 'issued_date': body.get('issued_date') or odoo_fields.Datetime.now(), + 'assignment_type': atype_id, + 'faculty_id': faculty_id, + } + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('subject_id'): + vals['subject_id'] = int(body['subject_id']) + if body.get('point') is not None: + vals['point'] = float(body['point']) + rec = request.env['grading.assignment'].sudo().create(vals) + return _json_response({'data': {'id': rec.id, 'name': rec.name}}) + except Exception as e: + _logger.exception('create_grading_assignment') + return _error_response(str(e), 500) + + @http.route('/api/grading-assignments/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_grading_assignment(self, gid, **kw): + try: + rec = request.env['grading.assignment'].sudo().browse(gid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'issued_date'): + if k in body: + vals[k] = body[k] + if 'course_id' in body: + vals['course_id'] = int(body['course_id']) + if 'subject_id' in body: + vals['subject_id'] = int(body['subject_id']) + if vals: + rec.write(vals) + return _json_response({'data': {'id': rec.id, 'name': rec.name}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/grading-assignments/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_grading_assignment(self, gid, **kw): + try: + rec = request.env['grading.assignment'].sudo().browse(gid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/institutional_exams.py b/backend/custom_addons/encoach_lms_api/controllers/institutional_exams.py new file mode 100644 index 00000000..1d499506 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/institutional_exams.py @@ -0,0 +1,753 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_exam(e): + return { + 'id': e.id, + 'name': e.name or '', + 'session_id': e.session_id.id if hasattr(e, 'session_id') and e.session_id else 0, + 'subject_id': e.subject_id.id if hasattr(e, 'subject_id') and e.subject_id else 0, + 'subject_name': e.subject_id.name if hasattr(e, 'subject_id') and e.subject_id else '', + 'exam_code': getattr(e, 'exam_code', '') or '', + 'start_time': str(e.start_time) if hasattr(e, 'start_time') and e.start_time else '', + 'end_time': str(e.end_time) if hasattr(e, 'end_time') and e.end_time else '', + 'total_marks': getattr(e, 'total_marks', 0) or 0, + 'min_marks': getattr(e, 'min_marks', 0) or 0, + 'state': getattr(e, 'state', 'draft') or 'draft', + 'responsible_names': [p.name for p in (getattr(e, 'responsible_ids', False) or [])], + 'attendee_count': len(getattr(e, 'attendees_line', False) or []), + } + + +def _ser_session(r, *, with_exams=False): + exams = [] + if with_exams and hasattr(r, 'exam_ids'): + exams = [_ser_exam(e) for e in r.exam_ids] + exam_count = len(getattr(r, 'exam_ids', False) or []) + exam_type = getattr(r, 'exam_type', False) + return { + 'id': r.id, + 'name': r.name or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'batch_id': r.batch_id.id if hasattr(r, 'batch_id') and r.batch_id else 0, + 'batch_name': r.batch_id.name if hasattr(r, 'batch_id') and r.batch_id else '', + 'exam_code': getattr(r, 'exam_code', '') or '', + 'start_date': str(r.start_date) if hasattr(r, 'start_date') and r.start_date else '', + 'end_date': str(r.end_date) if hasattr(r, 'end_date') and r.end_date else '', + 'exam_type_id': exam_type.id if exam_type else 0, + 'exam_type_name': exam_type.name if exam_type else '', + 'evaluation_type': getattr(r, 'evaluation_type', 'normal') or 'normal', + 'venue': getattr(r, 'venue', '') or '', + 'state': getattr(r, 'state', 'draft') or 'draft', + 'exam_count': exam_count, + 'exams': exams, + } + + +def _ser_template(r): + exam_session = getattr(r, 'exam_session_id', False) + grade_ids = [] + if hasattr(r, 'grade_ids'): + grade_ids = r.grade_ids.ids + return { + 'id': r.id, + 'name': r.name or '', + 'exam_session_id': exam_session.id if exam_session else 0, + 'exam_session_name': exam_session.name if exam_session else '', + 'evaluation_type': getattr(r, 'evaluation_type', 'normal') or 'normal', + 'result_date': str(r.result_date) if hasattr(r, 'result_date') and r.result_date else '', + 'grade_ids': grade_ids, + 'state': getattr(r, 'state', 'draft') or 'draft', + } + + +def _ser_marksheet_line(l): + return { + 'id': l.id, + 'student_id': l.student_id.id if hasattr(l, 'student_id') and l.student_id else 0, + 'student_name': l.student_id.name if hasattr(l, 'student_id') and l.student_id else '', + 'total_marks': getattr(l, 'total_marks', 0) or 0, + 'percentage': getattr(l, 'percentage', 0) or 0, + 'grade': getattr(l, 'grade', '') or '', + 'status': getattr(l, 'status', 'fail') or 'fail', + 'results': [], + } + + +def _ser_marksheet(r, *, with_lines=False): + exam_session = getattr(r, 'exam_session_id', False) + line_recs = getattr(r, 'marksheet_line', False) or [] + lines = [_ser_marksheet_line(l) for l in line_recs] if with_lines else [] + return { + 'id': r.id, + 'name': r.name or '', + 'exam_session_id': exam_session.id if exam_session else 0, + 'exam_session_name': exam_session.name if exam_session else '', + 'result_template_id': r.result_template_id.id if hasattr(r, 'result_template_id') and r.result_template_id else 0, + 'generated_date': str(r.generated_date) if hasattr(r, 'generated_date') and r.generated_date else '', + 'generated_by_name': r.generated_by.name if hasattr(r, 'generated_by') and r.generated_by else '', + 'state': getattr(r, 'state', 'draft') or 'draft', + 'total_pass': getattr(r, 'total_pass', 0) or 0, + 'total_failed': getattr(r, 'total_failed', 0) or 0, + 'lines': lines, + } + + +class InstitutionalExamsController(http.Controller): + + # ── Exam Sessions (op.exam.session) ────────────────────────────── + + @http.route('/api/inst-exam-sessions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_sessions(self, **kw): + try: + M = request.env['op.exam.session'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + q = (kw.get('q') or '').strip() + if q: + domain.append(('name', 'ilike', q)) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_session(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_sessions') + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_session(self, sid, **kw): + try: + r = request.env['op.exam.session'].sudo().browse(sid) + if not r.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_session(r, with_exams=True)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_session(self, **kw): + try: + body = _get_json_body() + Session = request.env['op.exam.session'].sudo() + vals = {'name': body.get('name', '')} + for fk in ('course_id', 'batch_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + if body.get('exam_type_id') and 'exam_type' in Session._fields: + vals['exam_type'] = int(body['exam_type_id']) + if body.get('evaluation_type') and 'evaluation_type' in Session._fields: + vals['evaluation_type'] = body['evaluation_type'] + if body.get('venue') and 'venue' in Session._fields: + vals['venue'] = body['venue'] + if body.get('exam_code') and 'exam_code' in Session._fields: + vals['exam_code'] = body['exam_code'] + for dt in ('start_date', 'end_date'): + if body.get(dt): + vals[dt] = body[dt] + rec = Session.create(vals) + return _json_response(_ser_session(rec, with_exams=True)) + except Exception as e: + _logger.exception('create_session') + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'start_date', 'end_date', 'venue', 'exam_code', 'evaluation_type'): + if k in body and k in rec._fields: + vals[k] = body[k] + for fk in ('course_id', 'batch_id'): + if fk in body and fk in rec._fields: + vals[fk] = int(body[fk]) if body[fk] else False + if 'exam_type_id' in body and 'exam_type' in rec._fields: + vals['exam_type'] = int(body['exam_type_id']) if body['exam_type_id'] else False + if vals: + rec.write(vals) + return _json_response(_ser_session(rec, with_exams=True)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions//schedule', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def schedule_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'schedule'}) + return _json_response({'id': rec.id, 'state': 'schedule'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions//done', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def done_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'done'}) + return _json_response({'id': rec.id, 'state': 'done'}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Exams (op.exam) ────────────────────────────────────────────── + + @http.route('/api/inst-exams', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_exam(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + for fk in ('session_id', 'subject_id', 'course_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + if body.get('total_marks'): + vals['total_marks'] = float(body['total_marks']) + rec = request.env['op.exam'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exams/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_exam(self, eid, **kw): + try: + rec = request.env['op.exam'].sudo().browse(eid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'total_marks'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exams//attendees', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def add_attendees(self, eid, **kw): + try: + body = _get_json_body() + student_ids = body.get('student_ids', []) + Att = request.env['op.exam.attendees'].sudo() + for sid in student_ids: + Att.create({'exam_id': eid, 'student_id': int(sid)}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exams//marks', type='http', auth='public', methods=['PATCH'], csrf=False) + @jwt_required + def enter_marks(self, eid, **kw): + try: + body = _get_json_body() + marks = body.get('marks', []) + Att = request.env['op.exam.attendees'].sudo() + for m in marks: + att = Att.search([('exam_id', '=', eid), ('student_id', '=', int(m['student_id']))], limit=1) + if att: + att.write({'marks': float(m.get('score', 0))}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Exam Types ─────────────────────────────────────────────────── + + @http.route('/api/exam-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_exam_types(self, **kw): + try: + M = request.env['op.exam.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{'id': r.id, 'name': r.name or ''} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/exam-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_exam_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['op.exam.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Grade Configurations ───────────────────────────────────────── + + @http.route('/api/grade-config', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_grade_config(self, **kw): + try: + M = request.env['op.grade.configuration'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{ + 'id': r.id, + 'name': r.name or '', + 'min_per': getattr(r, 'min_per', 0), + 'max_per': getattr(r, 'max_per', 0), + 'result': getattr(r, 'result', '') or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/grade-config', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_grade_config(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + for k in ('min_per', 'max_per'): + if body.get(k) is not None: + vals[k] = float(body[k]) + if body.get('result'): + vals['result'] = body['result'] + rec = request.env['op.grade.configuration'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Result Templates ───────────────────────────────────────────── + + @http.route('/api/result-templates', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_result_templates(self, **kw): + try: + M = request.env['op.result.template'].sudo() + domain = [] + if kw.get('exam_session_id'): + domain.append(('exam_session_id', '=', int(kw['exam_session_id']))) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_template(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_result_templates') + return _error_response(str(e), 500) + + @http.route('/api/result-templates', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_result_template(self, **kw): + try: + body = _get_json_body() + Tpl = request.env['op.result.template'].sudo() + vals = {'name': body.get('name', '')} + if body.get('exam_session_id') and 'exam_session_id' in Tpl._fields: + vals['exam_session_id'] = int(body['exam_session_id']) + if body.get('evaluation_type') and 'evaluation_type' in Tpl._fields: + vals['evaluation_type'] = body['evaluation_type'] + if body.get('result_date') and 'result_date' in Tpl._fields: + vals['result_date'] = body['result_date'] + grade_ids = body.get('grade_ids') or [] + if grade_ids and 'grade_ids' in Tpl._fields: + vals['grade_ids'] = [(6, 0, [int(g) for g in grade_ids])] + rec = Tpl.create(vals) + return _json_response(_ser_template(rec)) + except Exception as e: + _logger.exception('create_result_template') + return _error_response(str(e), 500) + + @http.route(['/api/result-templates//generate', + '/api/result-templates//generate-marksheets'], + type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def generate_marksheets(self, tid, **kw): + try: + rec = request.env['op.result.template'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + # OpenEduCat 19 names the method `generate_result`; + # older versions used `action_generate_marksheet`. + if hasattr(rec, 'generate_result'): + rec.generate_result() + elif hasattr(rec, 'action_generate_marksheet'): + rec.action_generate_marksheet() + else: + return _error_response( + 'No marksheet-generation method on op.result.template ' + '(expected generate_result or action_generate_marksheet).', + 400, + ) + return _json_response(_ser_template(rec)) + except Exception as e: + _logger.exception('generate_marksheets') + return _error_response(str(e), 500) + + @http.route('/api/result-templates/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_result_template(self, tid, **kw): + try: + rec = request.env['op.result.template'].sudo().browse(tid) + if rec.exists(): + # If this template already generated marksheet registers, + # unlink them first so the FK RESTRICT doesn't block us. + session = rec.exam_session_id + if session: + regs = request.env['op.marksheet.register'].sudo().search([ + ('result_template_id', '=', rec.id), + ]) + if regs: + # Unlink the lines + registers (CASCADE ok on lines). + regs.sudo().unlink() + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_result_template') + return _error_response(str(e), 500) + + @http.route('/api/result-templates/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_result_template(self, tid, **kw): + try: + rec = request.env['op.result.template'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'result_date', 'evaluation_type'): + if k in body and k in rec._fields: + vals[k] = body[k] + if 'grade_ids' in body and 'grade_ids' in rec._fields: + vals['grade_ids'] = [(6, 0, [int(g) for g in body['grade_ids']])] + if vals: + rec.write(vals) + return _json_response(_ser_template(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Marksheets ─────────────────────────────────────────────────── + + @http.route('/api/marksheets', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_marksheets(self, **kw): + try: + M = request.env['op.marksheet.register'].sudo() + domain = [] + if kw.get('exam_session_id'): + domain.append(('exam_session_id', '=', int(kw['exam_session_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_marksheet(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_marksheets') + return _error_response(str(e), 500) + + @http.route('/api/marksheets/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_marksheet(self, mid, **kw): + try: + rec = request.env['op.marksheet.register'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_marksheet(rec, with_lines=True)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/marksheets//validate', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def validate_marksheet(self, mid, **kw): + try: + rec = request.env['op.marksheet.register'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'action_validate'): + rec.action_validate() + else: + rec.write({'state': 'validated'}) + return _json_response(_ser_marksheet(rec, with_lines=True)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Course Assignments (OpenEduCat op.assignment) ───────────────── + + @http.route('/api/course-assignments', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_course_assignments(self, **kw): + try: + M = request.env['op.assignment'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'batch_id': r.batch_id.id if hasattr(r, 'batch_id') and r.batch_id else 0, + 'subject_id': r.subject_id.id if hasattr(r, 'subject_id') and r.subject_id else 0, + 'state': getattr(r, 'state', 'draft') or 'draft', + 'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_course_assignment(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + for fk in ('course_id', 'batch_id', 'subject_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + if body.get('issued_date'): + vals['issued_date'] = body['issued_date'] + rec = request.env['op.assignment'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_course_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'id': rec.id, 'name': rec.name or '', 'state': getattr(rec, 'state', 'draft')}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_course_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments//publish', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def publish_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'publish'}) + return _json_response({'id': rec.id, 'state': 'publish'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments//finish', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def finish_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'finish'}) + return _json_response({'id': rec.id, 'state': 'finish'}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Assignment Types ───────────────────────────────────────────── + + @http.route('/api/assignment-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_assignment_types(self, **kw): + try: + M = request.env['grading.assignment.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{'id': r.id, 'name': r.name or ''} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/assignment-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_assignment_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['grading.assignment.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Subject Registrations (op.subject.registration) ────────────── + + @http.route('/api/subject-registrations', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_subject_registrations(self, **kw): + try: + M = request.env['op.subject.registration'].sudo() + domain = [] + if kw.get('student_id'): + domain.append(('student_id', '=', int(kw['student_id']))) + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'state': getattr(r, 'state', 'draft') or 'draft', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_subject_registration(self, **kw): + try: + body = _get_json_body() + vals = {} + for fk in ('student_id', 'course_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + rec = request.env['op.subject.registration'].sudo().create(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_subject_registration(self, rid, **kw): + try: + rec = request.env['op.subject.registration'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations//confirm', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def confirm_registration(self, rid, **kw): + try: + rec = request.env['op.subject.registration'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'confirm'}) + return _json_response({'id': rec.id, 'state': 'confirm'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations//reject', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def reject_registration(self, rid, **kw): + try: + rec = request.env['op.subject.registration'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'reject'}) + return _json_response({'id': rec.id, 'state': 'reject'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations/available', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def available_subjects(self, **kw): + try: + return _json_response([]) + except Exception as e: + return _error_response(str(e), 500) + + # ── Submissions ────────────────────────────────────────────────── + + @http.route('/api/course-assignments//submissions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_submissions(self, aid, **kw): + try: + M = request.env['op.assignment.sub.line'].sudo() + domain = [('assignment_id', '=', aid)] + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'state': getattr(r, 'state', 'draft') or 'draft', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/submissions/', type='http', auth='public', methods=['PATCH'], csrf=False) + @jwt_required + def grade_submission(self, sid, **kw): + try: + rec = request.env['op.assignment.sub.line'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'marks' in body: + vals['marks'] = float(body['marks']) + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/lessons.py b/backend/custom_addons/encoach_lms_api/controllers/lessons.py new file mode 100644 index 00000000..4f7b01dc --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/lessons.py @@ -0,0 +1,106 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_lesson(r): + return { + 'id': r.id, + 'name': r.name or '', + 'lesson_topic': r.lesson_topic or '', + 'subject_id': r.subject_id.id if r.subject_id else 0, + 'subject_name': r.subject_id.name if r.subject_id else '', + 'session_id': r.session_id.id if r.session_id else 0, + 'session_name': r.session_id.display_name if r.session_id else '', + 'course_id': r.course_id.id if r.course_id else 0, + 'batch_id': r.batch_id.id if r.batch_id else 0, + 'faculty_id': r.faculty_id.id if r.faculty_id else 0, + 'start_datetime': str(r.start_datetime) if r.start_datetime else '', + 'end_datetime': str(r.end_datetime) if r.end_datetime else '', + 'status': r.status or 'draft', + } + + +class LessonsController(http.Controller): + + @http.route('/api/lessons', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_lessons(self, **kw): + try: + M = request.env['encoach.lesson'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='start_datetime desc, id desc') + items = [_ser_lesson(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_lesson(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('name'): + vals['name'] = body['name'] + if body.get('lesson_topic'): + vals['lesson_topic'] = body['lesson_topic'] + for fk in ('course_id', 'batch_id', 'subject_id', 'session_id', 'faculty_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + for dt in ('start_datetime', 'end_datetime'): + if body.get(dt): + vals[dt] = body[dt] + rec = request.env['encoach.lesson'].sudo().create(vals) + return _json_response(_ser_lesson(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_lesson(self, lid, **kw): + try: + rec = request.env['encoach.lesson'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_lesson(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_lesson(self, lid, **kw): + try: + rec = request.env['encoach.lesson'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'lesson_topic', 'start_datetime', 'end_datetime', 'status'): + if k in body: + vals[k] = body[k] + for fk in ('course_id', 'batch_id', 'subject_id', 'session_id', 'faculty_id'): + if fk in body: + vals[fk] = int(body[fk]) if body[fk] else False + if vals: + rec.write(vals) + return _json_response(_ser_lesson(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_lesson(self, lid, **kw): + try: + rec = request.env['encoach.lesson'].sudo().browse(lid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/library.py b/backend/custom_addons/encoach_lms_api/controllers/library.py new file mode 100644 index 00000000..27cc9819 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/library.py @@ -0,0 +1,215 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class LibraryController(http.Controller): + + # ── Media ──────────────────────────────────────────────────────── + + @http.route('/api/library/media', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_media(self, **kw): + try: + M = request.env['op.media'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'isbn': getattr(r, 'isbn', '') or '', + 'author': ', '.join(a.name for a in r.author_ids) if hasattr(r, 'author_ids') else '', + 'edition': getattr(r, 'edition', '') or '', + 'media_type': r.media_type_id.name if hasattr(r, 'media_type_id') and r.media_type_id else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_media') + return _error_response(str(e), 500) + + @http.route('/api/library/media', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_media(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('Name is required', 400) + Media = request.env['op.media'].sudo() + vals = {'name': name} + if body.get('isbn'): + vals['isbn'] = body['isbn'] + if body.get('edition'): + vals['edition'] = body['edition'] + + author_names = [] + raw_author = body.get('author') + if isinstance(raw_author, str) and raw_author.strip(): + author_names = [a.strip() for a in raw_author.split(',') if a.strip()] + raw_author_ids = body.get('author_ids') + author_ids = [] + if isinstance(raw_author_ids, (list, tuple)): + author_ids = [int(a) for a in raw_author_ids if a] + if author_names and 'author_ids' in Media._fields: + Author = request.env['op.author'].sudo() if 'op.author' in request.env else None + if Author is not None: + for nm in author_names: + existing = Author.search([('name', '=', nm)], limit=1) + if not existing: + existing = Author.create({'name': nm}) + author_ids.append(existing.id) + if author_ids and 'author_ids' in Media._fields: + vals['author_ids'] = [(6, 0, list(set(author_ids)))] + + rec = Media.create(vals) + return _json_response({'data': {'id': rec.id, 'name': rec.name}}) + except Exception as e: + _logger.exception('create_media') + return _error_response(str(e), 500) + + @http.route('/api/library/media/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_media(self, mid, **kw): + try: + rec = request.env['op.media'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + author_names = [a.name for a in rec.author_ids] if hasattr(rec, 'author_ids') else [] + return _json_response({ + 'id': rec.id, + 'name': rec.name or '', + 'isbn': getattr(rec, 'isbn', '') or '', + 'edition': getattr(rec, 'edition', '') or '', + 'author_ids': rec.author_ids.ids if hasattr(rec, 'author_ids') else [], + 'author_names': author_names, + 'author': ', '.join(author_names), + 'publisher_ids': rec.publisher_ids.ids if hasattr(rec, 'publisher_ids') else [], + 'media_type': rec.media_type_id.name if hasattr(rec, 'media_type_id') and rec.media_type_id else '', + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/media/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_media(self, mid, **kw): + try: + rec = request.env['op.media'].sudo().browse(mid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Movements ──────────────────────────────────────────────────── + + @http.route('/api/library/movements', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_movements(self, **kw): + try: + M = request.env['op.media.movement'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'media_id': r.media_id.id if r.media_id else 0, + 'media_name': r.media_id.name if r.media_id else '', + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'student_name': r.student_id.partner_id.name if hasattr(r, 'student_id') and r.student_id and r.student_id.partner_id else '', + 'faculty_id': r.faculty_id.id if hasattr(r, 'faculty_id') and r.faculty_id else 0, + 'faculty_name': r.faculty_id.partner_id.name if hasattr(r, 'faculty_id') and r.faculty_id and r.faculty_id.partner_id else '', + 'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '', + 'return_date': str(r.return_date) if hasattr(r, 'return_date') and r.return_date else '', + 'state': getattr(r, 'state', 'issue') or 'issue', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/movements', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_movement(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('media_id'): + vals['media_id'] = int(body['media_id']) + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + if body.get('faculty_id'): + vals['faculty_id'] = int(body['faculty_id']) + rec = request.env['op.media.movement'].sudo().create(vals) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/movements/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_movement(self, mid, **kw): + try: + rec = request.env['op.media.movement'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'return_date' in body: + vals['return_date'] = body['return_date'] + if vals: + rec.write(vals) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/movements//return', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def return_movement(self, mid, **kw): + try: + rec = request.env['op.media.movement'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import date + rec.write({'return_date': str(date.today()), 'state': 'return'}) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Library Cards ──────────────────────────────────────────────── + + @http.route('/api/library/cards', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_cards(self, **kw): + try: + M = request.env['op.library.card'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'number': r.number if hasattr(r, 'number') else str(r.id), + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'student_name': r.student_id.partner_id.name if hasattr(r, 'student_id') and r.student_id and r.student_id.partner_id else '', + 'faculty_id': r.faculty_id.id if hasattr(r, 'faculty_id') and r.faculty_id else 0, + 'faculty_name': r.faculty_id.partner_id.name if hasattr(r, 'faculty_id') and r.faculty_id and r.faculty_id.partner_id else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/cards', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_card(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + rec = request.env['op.library.card'].sudo().create(vals) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/lms_core.py b/backend/custom_addons/encoach_lms_api/controllers/lms_core.py new file mode 100644 index 00000000..dbee1de4 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/lms_core.py @@ -0,0 +1,854 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _serialize_course(c): + subj = getattr(c, 'encoach_subject_id', False) + tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag']) + topics = getattr(c, 'encoach_topic_ids', c.env['encoach.topic']) + objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective']) + enrolled_count = c.env['op.student.course'].sudo().search_count([('course_id', '=', c.id)]) + return { + 'id': c.id, + 'name': c.name or '', + 'title': c.name or '', + 'code': c.code or '', + 'description': getattr(c, 'description', '') or '', + 'status': 'active' if c.id else 'draft', + 'student_count': enrolled_count, + 'max_capacity': getattr(c, 'max_capacity', 0) or 0, + 'subject_ids': c.subject_ids.ids if hasattr(c, 'subject_ids') else [], + 'subject_names': [s.name for s in c.subject_ids] if hasattr(c, 'subject_ids') else [], + 'department_id': c.department_id.id if hasattr(c, 'department_id') and c.department_id else None, + 'department_name': c.department_id.name if hasattr(c, 'department_id') and c.department_id else '', + 'encoach_subject_id': subj.id if subj else None, + 'encoach_subject_name': subj.name if subj else '', + 'topic_ids': topics.ids, + 'topic_names': topics.mapped('name'), + 'learning_objective_ids': objectives.ids, + 'learning_objective_names': objectives.mapped('name'), + 'tag_ids': tags.ids, + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags], + 'difficulty_level': getattr(c, 'difficulty_level', '') or '', + 'cefr_level': getattr(c, 'cefr_level', '') or '', + 'chapter_count': getattr(c, 'chapter_count', 0) or 0, + 'resource_count': getattr(c, 'resource_count', 0) or 0, + 'objective_count': getattr(c, 'objective_count', 0) or 0, + } + + +def _serialize_student(s): + partner = s.partner_id + first = getattr(s, 'first_name', '') or (partner.name.split()[0] if partner.name else '') + last = getattr(s, 'last_name', '') or (' '.join(partner.name.split()[1:]) if partner.name else '') + course_ids = [] + batch_id = None + batch_name = '' + if hasattr(s, 'course_detail_ids'): + for cd in s.course_detail_ids: + if cd.course_id: + course_ids.append(cd.course_id.id) + if cd.batch_id and not batch_id: + batch_id = cd.batch_id.id + batch_name = cd.batch_id.name or '' + return { + 'id': s.id, + 'name': partner.name or '', + 'first_name': first, + 'last_name': last, + 'email': partner.email or '', + 'phone': partner.phone or getattr(partner, 'mobile', '') or '', + 'gender': s.gender or '', + 'enrollment_date': str(s.create_date.date()) if s.create_date else None, + 'status': 'active', + 'course_ids': course_ids, + 'batch_id': batch_id, + 'batch_name': batch_name, + 'partner_id': partner.id, + 'user_id': s.user_id.id if s.user_id else None, + } + + +def _serialize_teacher(f): + partner = f.partner_id + first = partner.name.split()[0] if partner.name else '' + last = ' '.join(partner.name.split()[1:]) if partner.name else '' + dept = getattr(f, 'department_id', False) + return { + 'id': f.id, + 'name': partner.name or '', + 'first_name': first, + 'last_name': last, + 'email': partner.email or '', + 'phone': partner.phone or getattr(partner, 'mobile', '') or '', + 'gender': f.gender or '', + 'birth_date': str(f.birth_date) if hasattr(f, 'birth_date') and f.birth_date else None, + 'partner_id': partner.id, + 'department_id': dept.id if dept else None, + 'department_name': dept.name if dept else '', + 'specialization': getattr(f, 'specialization', '') or '', + 'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [], + } + + +def _serialize_batch(b): + SC = b.env['op.student.course'].sudo() + student_recs = SC.search([('batch_id', '=', b.id)], limit=200) + seen = set() + students = [] + for sc in student_recs: + s = sc.student_id + if s and s.exists() and s.id not in seen: + seen.add(s.id) + partner = s.partner_id + students.append({ + 'id': s.id, + 'name': partner.name if partner else '', + 'email': partner.email or '' if partner else '', + }) + return { + 'id': b.id, + 'name': b.name or '', + 'code': b.code or '', + 'course_id': b.course_id.id if b.course_id else 0, + 'course_name': b.course_id.name if b.course_id else '', + 'start_date': str(b.start_date) if b.start_date else '', + 'end_date': str(b.end_date) if b.end_date else '', + 'status': 'active', + 'max_students': getattr(b, 'max_students', 0) or 0, + 'student_count': len(students), + 'students': students, + } + + +class LmsCoreController(http.Controller): + + # ── Courses ────────────────────────────────────────────────────── + + @http.route('/api/courses', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_courses(self, **kw): + try: + Course = request.env['op.course'].sudo() + domain = [] + if kw.get('status'): + pass # op.course has no status field by default + offset, limit, page = _paginate(kw) + total = Course.search_count(domain) + records = Course.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_course(r) for r in records], + 'data': [_serialize_course(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_courses failed') + return _error_response(str(e), 500) + + @http.route('/api/courses/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_course(self, course_id, **kw): + try: + rec = request.env['op.course'].sudo().browse(course_id) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _serialize_course(rec)}) + except Exception as e: + _logger.exception('get_course failed') + return _error_response(str(e), 500) + + @http.route('/api/courses', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_course(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'code': body.get('code', ''), + } + if body.get('description'): + vals['description'] = body['description'] + if body.get('max_capacity'): + vals['max_capacity'] = int(body['max_capacity']) + if body.get('encoach_subject_id'): + vals['encoach_subject_id'] = int(body['encoach_subject_id']) + if body.get('difficulty_level'): + vals['difficulty_level'] = body['difficulty_level'] + if body.get('cefr_level'): + vals['cefr_level'] = body['cefr_level'] + if body.get('topic_ids'): + vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])] + if body.get('learning_objective_ids'): + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + if body.get('tag_ids'): + vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])] + rec = request.env['op.course'].sudo().create(vals) + return _json_response({'data': _serialize_course(rec)}) + except Exception as e: + _logger.exception('create_course failed') + return _error_response(str(e), 500) + + @http.route('/api/courses/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_course(self, course_id, **kw): + try: + rec = request.env['op.course'].sudo().browse(course_id) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'description'): + if k in body: + vals[k] = body[k] + if 'max_capacity' in body: + vals['max_capacity'] = int(body['max_capacity']) + if 'encoach_subject_id' in body: + vals['encoach_subject_id'] = int(body['encoach_subject_id']) if body['encoach_subject_id'] else False + if 'difficulty_level' in body: + vals['difficulty_level'] = body['difficulty_level'] or False + if 'cefr_level' in body: + vals['cefr_level'] = body['cefr_level'] or False + if 'topic_ids' in body: + vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])] + if 'learning_objective_ids' in body: + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + if 'tag_ids' in body: + vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])] + if vals: + rec.write(vals) + return _json_response({'data': _serialize_course(rec)}) + except Exception as e: + _logger.exception('update_course failed') + return _error_response(str(e), 500) + + @http.route('/api/courses/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_course(self, course_id, **kw): + try: + rec = request.env['op.course'].sudo().browse(course_id) + if not rec.exists(): + return _json_response({'success': True}) + force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes') + enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)]) + if enrollments and not force: + return _error_response( + f'Cannot delete: {len(enrollments)} student enrollment(s) reference this course. ' + f'Pass force=true to cascade-delete.', 409) + if force and enrollments: + enrollments.unlink() + batches = request.env['op.batch'].sudo().search([('course_id', '=', course_id)]) + if batches and force: + for b in batches: + b.course_id = False + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_course failed') + return _error_response(str(e), 500) + + # ── Student: My Courses (enrollment-filtered) ────────────────── + + @http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def student_my_courses(self, **kw): + """Return only courses the current JWT user is enrolled in via op.student.course.""" + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _json_response({'items': [], 'total': 0}) + course_details = request.env['op.student.course'].sudo().search([ + ('student_id', '=', student.id) + ]) + course_ids = course_details.mapped('course_id') + items = [] + for c in course_ids: + data = _serialize_course(c) + cd = course_details.filtered(lambda d: d.course_id.id == c.id) + data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None + data['batch_name'] = cd[0].batch_id.name if cd and cd[0].batch_id else '' + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', c.id) + ]) + total_materials = sum(ch.material_count for ch in chapters) + progress_recs = request.env['encoach.chapter.progress'].sudo().search([ + ('student_id', '=', student.id), + ('chapter_id', 'in', chapters.ids), + ]) + completed_chapters = len(progress_recs.filtered(lambda p: p.status == 'completed')) + data['chapter_count'] = len(chapters) + data['total_materials'] = total_materials + data['completed_chapters'] = completed_chapters + data['progress'] = int((completed_chapters / len(chapters) * 100) if chapters else 0) + items.append(data) + return _json_response({'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('student_my_courses failed') + return _error_response(str(e), 500) + + # ── Enroll existing student in a course ────────────────────── + + @http.route('/api/students//enroll', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def enroll_student(self, student_id, **kw): + """Enroll an existing student in one or more courses.""" + try: + student = request.env['op.student'].sudo().browse(student_id) + if not student.exists(): + return _error_response('Student not found', 404) + body = _get_json_body() + course_id = body.get('course_id') + course_ids = body.get('course_ids', []) + if course_id: + course_ids.append(int(course_id)) + batch_id = body.get('batch_id') + enrolled = [] + SC = request.env['op.student.course'].sudo() + for cid in course_ids: + cid = int(cid) + existing = SC.search([ + ('student_id', '=', student.id), + ('course_id', '=', cid), + ], limit=1) + if existing: + continue + vals = {'student_id': student.id, 'course_id': cid} + if batch_id: + vals['batch_id'] = int(batch_id) + SC.create(vals) + enrolled.append(cid) + return _json_response({ + 'success': True, + 'enrolled_course_ids': enrolled, + 'student': _serialize_student(student), + }) + except Exception as e: + _logger.exception('enroll_student failed') + return _error_response(str(e), 500) + + # ── Bulk enroll students in a course ───────────────────────── + + @http.route('/api/courses//enroll', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def enroll_students_in_course(self, course_id, **kw): + """Enroll multiple students (or a whole batch) into a course.""" + try: + course = request.env['op.course'].sudo().browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + body = _get_json_body() + student_ids = [int(sid) for sid in body.get('student_ids', [])] + batch_id = body.get('batch_id') + if batch_id and not student_ids: + students_in_batch = request.env['op.student'].sudo().search([ + ('course_detail_ids.batch_id', '=', int(batch_id)) + ]) + student_ids = students_in_batch.ids + SC = request.env['op.student.course'].sudo() + enrolled = [] + for sid in student_ids: + existing = SC.search([ + ('student_id', '=', sid), + ('course_id', '=', course_id), + ], limit=1) + if existing: + continue + vals = {'student_id': sid, 'course_id': course_id} + if batch_id: + vals['batch_id'] = int(batch_id) + SC.create(vals) + enrolled.append(sid) + return _json_response({ + 'success': True, + 'enrolled_student_ids': enrolled, + 'total_enrolled': len(enrolled), + }) + except Exception as e: + _logger.exception('enroll_students_in_course failed') + return _error_response(str(e), 500) + + # ── Students ───────────────────────────────────────────────────── + + @http.route('/api/students', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_students(self, **kw): + try: + Student = request.env['op.student'].sudo() + domain = [] + if kw.get('search'): + domain = [('partner_id.name', 'ilike', kw['search'])] + if kw.get('batch_id'): + domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id']))) + offset, limit, page = _paginate(kw) + total = Student.search_count(domain) + records = Student.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_student(r) for r in records], + 'data': [_serialize_student(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_students failed') + return _error_response(str(e), 500) + + @http.route('/api/students/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_student(self, student_id, **kw): + try: + rec = request.env['op.student'].sudo().browse(student_id) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _serialize_student(rec)}) + except Exception as e: + _logger.exception('get_student failed') + return _error_response(str(e), 500) + + @http.route('/api/students', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_student(self, **kw): + try: + body = _get_json_body() + first = body.get('first_name', '') + last = body.get('last_name', '') + name = f"{first} {last}".strip() + partner_vals = { + 'name': name, + 'email': body.get('email', ''), + 'phone': body.get('phone', ''), + } + partner = request.env['res.partner'].sudo().create(partner_vals) + student_vals = { + 'partner_id': partner.id, + 'gender': body.get('gender', ''), + } + if body.get('birth_date'): + student_vals['birth_date'] = body['birth_date'] + student = request.env['op.student'].sudo().create(student_vals) + if body.get('course_id'): + cd_vals = { + 'student_id': student.id, + 'course_id': int(body['course_id']), + } + if body.get('batch_id'): + cd_vals['batch_id'] = int(body['batch_id']) + request.env['op.student.course'].sudo().create(cd_vals) + if body.get('create_portal_user', True) and body.get('email'): + user = request.env['res.users'].sudo().create({ + 'name': name, + 'login': body['email'], + 'email': body['email'], + 'password': body.get('password', 'student123'), + 'partner_id': partner.id, + }) + student.sudo().write({'user_id': user.id}) + return _json_response({'data': _serialize_student(student)}) + except Exception as e: + _logger.exception('create_student failed') + return _error_response(str(e), 500) + + @http.route('/api/students/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_student(self, student_id, **kw): + try: + rec = request.env['op.student'].sudo().browse(student_id) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + partner_vals = {} + if 'first_name' in body or 'last_name' in body: + first = body.get('first_name', rec.partner_id.name.split()[0] if rec.partner_id.name else '') + last = body.get('last_name', ' '.join(rec.partner_id.name.split()[1:]) if rec.partner_id.name else '') + partner_vals['name'] = f"{first} {last}".strip() + if 'email' in body: + partner_vals['email'] = body['email'] + if 'phone' in body: + partner_vals['phone'] = body['phone'] + if partner_vals: + rec.partner_id.sudo().write(partner_vals) + student_vals = {} + if 'gender' in body: + student_vals['gender'] = body['gender'] + if student_vals: + rec.write(student_vals) + return _json_response({'data': _serialize_student(rec)}) + except Exception as e: + _logger.exception('update_student failed') + return _error_response(str(e), 500) + + @http.route('/api/students/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_student(self, student_id, **kw): + try: + rec = request.env['op.student'].sudo().browse(student_id) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_student failed') + return _error_response(str(e), 500) + + # ── Teachers ───────────────────────────────────────────────────── + + @http.route('/api/teachers', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_teachers(self, **kw): + try: + Faculty = request.env['op.faculty'].sudo() + domain = [] + if kw.get('search'): + domain = [('partner_id.name', 'ilike', kw['search'])] + offset, limit, page = _paginate(kw) + total = Faculty.search_count(domain) + records = Faculty.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_teacher(r) for r in records], + 'data': [_serialize_teacher(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_teachers failed') + return _error_response(str(e), 500) + + @http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_teacher(self, **kw): + try: + body = _get_json_body() + first = body.get('first_name', '') + last = body.get('last_name', '') + name = f"{first} {last}".strip() + partner = request.env['res.partner'].sudo().create({ + 'name': name, + 'email': body.get('email', ''), + 'phone': body.get('phone', ''), + }) + fac_vals = { + 'partner_id': partner.id, + 'gender': body.get('gender', ''), + } + if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'): + fac_vals['department_id'] = int(body['department_id']) + if body.get('birth_date'): + fac_vals['birth_date'] = body['birth_date'] + faculty = request.env['op.faculty'].sudo().create(fac_vals) + return _json_response({'data': _serialize_teacher(faculty)}) + except Exception as e: + _logger.exception('create_teacher failed') + return _error_response(str(e), 500) + + @http.route('/api/teachers/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_teacher(self, teacher_id, **kw): + try: + rec = request.env['op.faculty'].sudo().browse(teacher_id) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_teacher failed') + return _error_response(str(e), 500) + + # ── Batches ────────────────────────────────────────────────────── + + @http.route('/api/batches', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_batches(self, **kw): + try: + Batch = request.env['op.batch'].sudo() + domain = [] + offset, limit, page = _paginate(kw) + total = Batch.search_count(domain) + records = Batch.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_batch(r) for r in records], + 'data': [_serialize_batch(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_batches failed') + return _error_response(str(e), 500) + + @http.route('/api/batches/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_batch(self, batch_id, **kw): + try: + rec = request.env['op.batch'].sudo().browse(batch_id) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _serialize_batch(rec)}) + except Exception as e: + _logger.exception('get_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_batch(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('start_date'): + vals['start_date'] = body['start_date'] + if body.get('end_date'): + vals['end_date'] = body['end_date'] + rec = request.env['op.batch'].sudo().create(vals) + return _json_response({'data': _serialize_batch(rec)}) + except Exception as e: + _logger.exception('create_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_batch(self, batch_id, **kw): + try: + rec = request.env['op.batch'].sudo().browse(batch_id) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + if 'course_id' in body: + vals['course_id'] = int(body['course_id']) + if vals: + rec.write(vals) + return _json_response({'data': _serialize_batch(rec)}) + except Exception as e: + _logger.exception('update_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_batch(self, batch_id, **kw): + try: + rec = request.env['op.batch'].sudo().browse(batch_id) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches//students', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_batch_students(self, batch_id, **kw): + try: + SC = request.env['op.student.course'].sudo() + recs = SC.search([('batch_id', '=', batch_id)]) + students = [] + for sc in recs: + s = sc.student_id + if s and s.exists(): + partner = s.partner_id + students.append({ + 'id': s.id, + 'name': partner.name if partner else '', + 'email': partner.email or '' if partner else '', + 'course_id': sc.course_id.id if sc.course_id else 0, + 'course_name': sc.course_id.name if sc.course_id else '', + }) + return _json_response({'data': students, 'total': len(students)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/batches//students', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def add_students_to_batch(self, batch_id, **kw): + """Add students to a batch. Creates op.student.course links with batch_id.""" + try: + batch = request.env['op.batch'].sudo().browse(batch_id) + if not batch.exists(): + return _error_response('Batch not found', 404) + body = _get_json_body() + student_ids = [int(s) for s in body.get('student_ids', [])] + SC = request.env['op.student.course'].sudo() + added = [] + for sid in student_ids: + existing = SC.search([ + ('student_id', '=', sid), + ('batch_id', '=', batch_id), + ], limit=1) + if existing: + continue + vals = {'student_id': sid, 'batch_id': batch_id} + if batch.course_id: + vals['course_id'] = batch.course_id.id + dup = SC.search([ + ('student_id', '=', sid), + ('course_id', '=', batch.course_id.id), + ], limit=1) + if dup: + dup.write({'batch_id': batch_id}) + added.append(sid) + continue + SC.create(vals) + added.append(sid) + return _json_response({ + 'success': True, + 'added_ids': added, + 'total': len(added), + 'data': _serialize_batch(batch), + }) + except Exception as e: + _logger.exception('add_students_to_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches//students/remove', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def remove_students_from_batch(self, batch_id, **kw): + """Remove students from a batch by clearing their batch_id.""" + try: + body = _get_json_body() + student_ids = [int(s) for s in body.get('student_ids', [])] + SC = request.env['op.student.course'].sudo() + removed = [] + for sid in student_ids: + recs = SC.search([ + ('student_id', '=', sid), + ('batch_id', '=', batch_id), + ]) + if recs: + recs.write({'batch_id': False}) + removed.append(sid) + batch = request.env['op.batch'].sudo().browse(batch_id) + return _json_response({ + 'success': True, + 'removed_ids': removed, + 'total': len(removed), + 'data': _serialize_batch(batch) if batch.exists() else {}, + }) + except Exception as e: + return _error_response(str(e), 500) + + # ── Taxonomy Relationship Endpoints ───────────────────────────── + + @http.route('/api/courses//taxonomy', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def course_taxonomy(self, course_id, **kw): + """Return the taxonomy tree relevant to a course: its subject, topics, and objectives.""" + try: + course = request.env['op.course'].sudo().browse(course_id) + if not course.exists(): + return _error_response('Not found', 404) + subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False + topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic'] + objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective'] + domains = topics.mapped('domain_id') + return _json_response({ + 'subject': {'id': subj.id, 'name': subj.name, 'code': subj.code or ''} if subj else None, + 'domains': [{'id': d.id, 'name': d.name} for d in domains], + 'topics': [{'id': t.id, 'name': t.name, 'domain_id': t.domain_id.id, 'domain_name': t.domain_id.name} for t in topics], + 'objectives': [{'id': o.id, 'name': o.name, 'topic_id': o.topic_id.id, 'topic_name': o.topic_id.name, 'bloom_level': o.bloom_level or ''} for o in objectives], + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in (course.encoach_tag_ids if hasattr(course, 'encoach_tag_ids') else [])], + }) + except Exception as e: + _logger.exception('course_taxonomy failed') + return _error_response(str(e), 500) + + @http.route('/api/subjects//courses', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def subject_courses(self, subject_id, **kw): + """Return all courses linked to a given taxonomy subject.""" + try: + courses = request.env['op.course'].sudo().search([ + ('encoach_subject_id', '=', subject_id) + ]) + return _json_response({ + 'items': [_serialize_course(c) for c in courses], + 'total': len(courses), + }) + except Exception as e: + _logger.exception('subject_courses failed') + return _error_response(str(e), 500) + + @http.route('/api/topics//resources', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def topic_resources(self, topic_id, **kw): + """Return all resources linked to a given taxonomy topic.""" + try: + resources = request.env['encoach.resource'].sudo().search([ + ('topic_ids', 'in', [topic_id]) + ]) + from odoo.addons.encoach_lms_api.controllers.resources import _ser_resource + return _json_response({ + 'items': [_ser_resource(r) for r in resources], + 'total': len(resources), + }) + except Exception as e: + _logger.exception('topic_resources failed') + return _error_response(str(e), 500) + + @http.route('/api/learning-objectives', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_learning_objectives(self, **kw): + """List learning objectives, optionally filtered by topic_id or subject_id.""" + try: + LO = request.env['encoach.learning.objective'].sudo() + domain = [('active', '=', True)] + if kw.get('topic_id'): + domain.append(('topic_id', '=', int(kw['topic_id']))) + elif kw.get('topic_ids'): + ids = [int(x) for x in str(kw['topic_ids']).split(',') if x.strip()] + domain.append(('topic_id', 'in', ids)) + elif kw.get('subject_id'): + topics = request.env['encoach.topic'].sudo().search([ + ('domain_id.subject_id', '=', int(kw['subject_id'])) + ]) + domain.append(('topic_id', 'in', topics.ids)) + recs = LO.search(domain, order='sequence, name') + items = [{ + 'id': o.id, + 'name': o.name, + 'description': o.description or '', + 'topic_id': o.topic_id.id, + 'topic_name': o.topic_id.name, + 'bloom_level': o.bloom_level or '', + 'cefr_level': o.cefr_level or '', + } for o in recs] + return _json_response({'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('list_learning_objectives failed') + return _error_response(str(e), 500) + + @http.route('/api/domains', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_domains_lms(self, **kw): + """List domains, optionally filtered by subject_id.""" + try: + D = request.env['encoach.domain'].sudo() + domain = [('active', '=', True)] + if kw.get('subject_id'): + domain.append(('subject_id', '=', int(kw['subject_id']))) + recs = D.search(domain, order='sequence, name') + items = [{ + 'id': d.id, + 'name': d.name, + 'subject_id': d.subject_id.id, + 'subject_name': d.subject_id.name, + 'description': d.description or '', + } for d in recs] + return _json_response({'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('list_domains_lms failed') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/notifications.py b/backend/custom_addons/encoach_lms_api/controllers/notifications.py new file mode 100644 index 00000000..c4076eb3 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/notifications.py @@ -0,0 +1,229 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_notif(n): + return { + 'id': n.id, + 'title': n.title or '', + 'message': n.message or '', + 'type': n.type or 'info', + 'is_read': n.is_read, + 'read_at': str(n.read_at) if n.read_at else None, + 'reference_model': n.reference_model or None, + 'reference_id': n.reference_id or None, + 'created_at': str(n.create_date) if n.create_date else '', + } + + +def _ser_rule(r): + return { + 'id': r.id, + 'name': r.name or '', + 'event_type': r.event_type or '', + 'template': r.template or '', + # Admin UI uses `active`; legacy callers still read `is_active`. + 'active': bool(r.is_active), + 'is_active': bool(r.is_active), + 'recipients': r.recipients or 'all', + 'channels': r.channels or '', + 'days_before': r.days_before or 0, + 'frequency': r.frequency or 'once', + 'channel': r.channel or 'in_app', + 'entity_id': r.entity_id.id or None, + 'entity_name': r.entity_id.name if r.entity_id else None, + } + + +def _apply_rule_writes(body, vals): + """Translate the incoming JSON body into ORM write values. + Accepts both the new (`active`, `channel`, `days_before`, `frequency`) + and legacy (`is_active`, `channels`) contracts. + """ + for k in ('name', 'event_type', 'template', 'recipients', 'frequency', 'channel'): + if k in body and body[k] is not None: + vals[k] = body[k] + if 'channels' in body and body['channels'] is not None: + vals['channels'] = body['channels'] + if 'days_before' in body and body['days_before'] is not None: + try: + vals['days_before'] = int(body['days_before']) + except (TypeError, ValueError): + pass + if 'entity_id' in body: + vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False + if 'active' in body: + vals['is_active'] = bool(body['active']) + elif 'is_active' in body: + vals['is_active'] = bool(body['is_active']) + return vals + + +class NotificationsController(http.Controller): + + @http.route('/api/notifications', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_notifications(self, **kw): + try: + M = request.env['encoach.notification'].sudo() + domain = [('user_id', '=', request.env.uid)] + if kw.get('type'): + domain.append(('type', '=', kw['type'])) + if kw.get('is_read') == 'false': + domain.append(('is_read', '=', False)) + elif kw.get('is_read') == 'true': + domain.append(('is_read', '=', True)) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='create_date desc') + items = [_ser_notif(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notifications//read', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_read(self, nid, **kw): + try: + rec = request.env['encoach.notification'].sudo().browse(nid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import datetime + rec.write({'is_read': True, 'read_at': str(datetime.now())}) + return _json_response(_ser_notif(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notifications/read-all', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_all_read(self, **kw): + try: + from datetime import datetime + recs = request.env['encoach.notification'].sudo().search([ + ('user_id', '=', request.env.uid), ('is_read', '=', False) + ]) + recs.write({'is_read': True, 'read_at': str(datetime.now())}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notifications/unread-count', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def unread_count(self, **kw): + try: + count = request.env['encoach.notification'].sudo().search_count([ + ('user_id', '=', request.env.uid), ('is_read', '=', False) + ]) + return _json_response({'count': count}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Rules ──────────────────────────────────────────────────────── + + @http.route('/api/notification-rules', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_rules(self, **kw): + try: + recs = request.env['encoach.notification.rule'].sudo().search([], order='id desc') + return _json_response([_ser_rule(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notification-rules', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_rule(self, **kw): + try: + body = _get_json_body() + if not body.get('name'): + return _error_response('name is required', 400) + if not body.get('event_type'): + return _error_response('event_type is required', 400) + vals = { + 'name': body.get('name', ''), + 'event_type': body.get('event_type', ''), + } + _apply_rule_writes(body, vals) + rec = request.env['encoach.notification.rule'].sudo().create(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + _logger.exception('create notification rule failed') + return _error_response(str(e), 500) + + @http.route('/api/notification-rules/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_rule(self, rid, **kw): + try: + rec = request.env['encoach.notification.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + _apply_rule_writes(body, vals) + if vals: + rec.write(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + _logger.exception('update notification rule failed') + return _error_response(str(e), 500) + + @http.route('/api/notification-rules/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_rule(self, rid, **kw): + try: + rec = request.env['encoach.notification.rule'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Preferences ────────────────────────────────────────────────── + + @http.route('/api/notification-preferences', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_preferences(self, **kw): + try: + M = request.env['encoach.notification.preference'].sudo() + rec = M.search([('user_id', '=', request.env.uid)], limit=1) + if not rec: + rec = M.create({'user_id': request.env.uid}) + return _json_response({ + 'email_enabled': rec.email_enabled, + 'push_enabled': rec.push_enabled, + 'in_app_enabled': rec.in_app_enabled, + 'digest_frequency': rec.digest_frequency, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notification-preferences', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_preferences(self, **kw): + try: + M = request.env['encoach.notification.preference'].sudo() + rec = M.search([('user_id', '=', request.env.uid)], limit=1) + if not rec: + rec = M.create({'user_id': request.env.uid}) + body = _get_json_body() + vals = {} + for k in ('email_enabled', 'push_enabled', 'in_app_enabled'): + if k in body: + vals[k] = bool(body[k]) + if 'digest_frequency' in body: + vals['digest_frequency'] = body['digest_frequency'] + if vals: + rec.write(vals) + return _json_response({ + 'email_enabled': rec.email_enabled, + 'push_enabled': rec.push_enabled, + 'in_app_enabled': rec.in_app_enabled, + 'digest_frequency': rec.digest_frequency, + }) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/payments.py b/backend/custom_addons/encoach_lms_api/controllers/payments.py new file mode 100644 index 00000000..9af4735c --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/payments.py @@ -0,0 +1,151 @@ +"""Payment Record endpoints — surfaces real accounting/fees data to the +`/admin/payment-record` page (previously mocked). +""" +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, +) + +_logger = logging.getLogger(__name__) + + +def _ser_fee_as_payment(rec): + """Serialize an op.student.fees.details row as a 'payment record'.""" + inv = rec.invoice_id if hasattr(rec, 'invoice_id') else False + return { + 'id': rec.id, + 'ref': f'FEE-{rec.id:04d}', + 'student_id': rec.student_id.id if rec.student_id else None, + 'student_name': rec.student_id.name if rec.student_id else '', + 'course_id': rec.course_id.id if rec.course_id else None, + 'course_name': rec.course_id.name if rec.course_id else '', + 'product_name': rec.product_id.name if rec.product_id else '', + 'amount': float(rec.amount or 0.0), + 'after_discount_amount': float(getattr(rec, 'after_discount_amount', rec.amount) or 0.0), + 'currency': rec.currency_id.name if hasattr(rec, 'currency_id') and rec.currency_id else 'USD', + 'state': rec.state or 'draft', + 'paid': rec.state in ('paid', 'post'), + 'date': str(rec.date) if rec.date else '', + 'type': 'Student Fees', + 'invoice_id': inv.id if inv else None, + 'invoice_number': inv.name if inv and inv.name and inv.name != '/' else '', + 'payment_state': inv.payment_state if inv and hasattr(inv, 'payment_state') else '', + } + + +def _ser_invoice(inv): + return { + 'id': inv.id, + 'ref': inv.name or f'INV-{inv.id}', + 'partner_name': inv.partner_id.name if inv.partner_id else '', + 'amount': float(inv.amount_total or 0.0), + 'currency': inv.currency_id.name if inv.currency_id else 'USD', + 'state': inv.state or 'draft', + 'payment_state': getattr(inv, 'payment_state', '') or '', + 'paid': getattr(inv, 'payment_state', '') == 'paid', + 'date': str(inv.invoice_date) if inv.invoice_date else str(inv.date) if inv.date else '', + 'type': 'Invoice', + } + + +class PaymentRecordsController(http.Controller): + + @http.route('/api/payment-records', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_payments(self, **kw): + """Return a unified list of payment records sourced from student fees + (and optionally accounting invoices). Supports filters: status, paid, + student_id.""" + try: + FeesModel = request.env['op.student.fees.details'].sudo() + domain = [] + status = kw.get('status') + if status and status != 'all': + domain.append(('state', '=', status)) + if kw.get('student_id'): + try: + domain.append(('student_id', '=', int(kw['student_id']))) + except (TypeError, ValueError): + pass + recs = FeesModel.search(domain, limit=200) + items = [_ser_fee_as_payment(r) for r in recs] + paid_filter = kw.get('paid') + if paid_filter in ('true', 'false'): + want = paid_filter == 'true' + items = [i for i in items if i['paid'] == want] + return _json_response({ + 'data': items, + 'items': items, + 'total': len(items), + 'totals': { + 'count': len(items), + 'paid': sum(1 for i in items if i['paid']), + 'unpaid': sum(1 for i in items if not i['paid']), + 'amount': sum(i['amount'] for i in items), + }, + }) + except Exception as e: + _logger.exception('list_payments') + return _error_response(str(e), 500) + + @http.route('/api/payment-records/invoices', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_invoices(self, **kw): + """Raw accounting invoices (account.move with out_invoice).""" + try: + Move = request.env['account.move'].sudo() + domain = [('move_type', '=', 'out_invoice')] + try: + size = min(int(kw.get('size') or 50), 200) + except (TypeError, ValueError): + size = 50 + recs = Move.search(domain, limit=size, order='id desc') + items = [_ser_invoice(m) for m in recs] + return _json_response({ + 'data': items, 'items': items, 'total': len(items), + }) + except Exception as e: + _logger.exception('list_invoices') + return _error_response(str(e), 500) + + @http.route('/api/paymob-orders', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_paymob_orders(self, **kw): + """List Paymob orders for the calling user. + + Admins see all orders; regular users see only their own. Orders are + populated by the paymob.py controller's checkout + webhook flow. + """ + try: + Order = request.env['encoach.paymob.order'].sudo() + domain = [] + if not request.env.user.has_group('base.group_system'): + domain.append(('user_id', '=', request.env.user.id)) + rows = Order.search(domain, order='create_date desc', limit=200) + items = [{ + 'id': r.id, + 'reference': r.reference, + 'amount': float(r.amount_cents or 0) / 100.0, + 'currency': r.currency, + 'state': r.state, + 'paid': r.state == 'paid', + 'description': r.description or '', + 'product_ref': r.product_ref or '', + 'paymob_order_id': r.paymob_order_id or '', + 'user_id': r.user_id.id if r.user_id else None, + 'user_name': r.user_id.name if r.user_id else '', + 'date': str(r.create_date) if r.create_date else '', + 'last_event_at': str(r.last_event_at) if r.last_event_at else '', + 'type': 'Paymob', + } for r in rows] + return _json_response({ + 'data': items, 'items': items, 'total': len(items), + }) + except Exception as e: + _logger.exception('list_paymob_orders') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/paymob.py b/backend/custom_addons/encoach_lms_api/controllers/paymob.py new file mode 100644 index 00000000..32f09c40 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/paymob.py @@ -0,0 +1,351 @@ +"""Paymob checkout initiator + HMAC-verified webhook. + +This module talks to the `Paymob Accept `_ API: + +1. ``POST /api/payments/paymob/checkout`` + Authenticates with the merchant API key, registers an order, and + generates a payment key. Returns the ``payment_key`` + the hosted iframe + URL the frontend should redirect the user to. + +2. ``POST /api/payments/paymob/webhook`` + Verifies Paymob's HMAC-SHA512 signature against a concatenated subset + of the transaction fields (per Paymob docs), marks the matching + ``encoach.paymob.order`` as ``paid`` or ``failed``, and idempotently + no-ops on duplicate deliveries. + +Configuration (via ``ir.config_parameter``): + +* ``encoach.paymob.api_key`` — merchant API key +* ``encoach.paymob.hmac_secret`` — HMAC secret (from the Paymob dashboard) +* ``encoach.paymob.integration_id`` — default integration id (card) +* ``encoach.paymob.iframe_id`` — hosted iframe id for redirect URL + +All secrets are read at request time so operators can rotate them without +restarting Odoo. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from typing import Any + +import requests + +from odoo import fields, http +from odoo.http import Response, request + +from odoo.addons.encoach_api.controllers.base import ( + _error_response, + _get_json_body, + _json_response, + jwt_required, +) + +_logger = logging.getLogger(__name__) + +PAYMOB_BASE = "https://accept.paymob.com/api" + +# Fields Paymob concatenates (in this exact order) to compute the HMAC we +# receive on webhook callbacks. Source: Paymob Accept docs, Transaction +# Processed Callback → "HMAC Calculation" section. +HMAC_FIELDS = [ + "amount_cents", + "created_at", + "currency", + "error_occured", + "has_parent_transaction", + "id", + "integration_id", + "is_3d_secure", + "is_auth", + "is_capture", + "is_refunded", + "is_standalone_payment", + "is_voided", + "order.id", + "owner", + "pending", + "source_data.pan", + "source_data.sub_type", + "source_data.type", + "success", +] + + +def _config(key: str, default: str = "") -> str: + return ( + request.env["ir.config_parameter"].sudo().get_param(f"encoach.paymob.{key}", default) + or "" + ) + + +def _paymob_post(path: str, body: dict, timeout: int = 15) -> dict: + url = f"{PAYMOB_BASE}{path}" + resp = requests.post(url, json=body, timeout=timeout) + if resp.status_code >= 400: + raise RuntimeError(f"Paymob {path} → {resp.status_code}: {resp.text[:300]}") + try: + return resp.json() + except ValueError as exc: + raise RuntimeError(f"Paymob {path} returned non-JSON: {resp.text[:300]}") from exc + + +def _get_nested(obj: dict, dotted: str) -> Any: + """Walk 'a.b.c' through nested dicts, returning '' on any miss.""" + cur: Any = obj + for part in dotted.split("."): + if not isinstance(cur, dict): + return "" + cur = cur.get(part, "") + return cur if cur is not None else "" + + +def _compute_hmac(obj: dict, secret: str) -> str: + """Reproduce Paymob's HMAC-SHA512 over the canonical field sequence.""" + parts = [str(_get_nested(obj, f)).lower() if isinstance(_get_nested(obj, f), bool) + else str(_get_nested(obj, f)) + for f in HMAC_FIELDS] + message = "".join(parts).encode("utf-8") + return hmac.new(secret.encode("utf-8"), message, hashlib.sha512).hexdigest() + + +class EncoachPaymobController(http.Controller): + """Checkout + webhook endpoints.""" + + # ------------------------------------------------------------------ + # POST /api/payments/paymob/checkout + # + # Body: { amount_cents, currency, product_ref?, description?, integration_id? } + # ------------------------------------------------------------------ + @http.route( + "/api/payments/paymob/checkout", + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def checkout(self, **kw): + try: + api_key = _config("api_key") + iframe_id = _config("iframe_id") + default_integration = _config("integration_id") + if not api_key or not iframe_id or not default_integration: + return _error_response( + "Paymob is not configured. " + "Set encoach.paymob.api_key / hmac_secret / integration_id / iframe_id " + "in System Parameters.", + 503, + ) + + body = _get_json_body() or {} + try: + amount_cents = int(body.get("amount_cents") or 0) + except (TypeError, ValueError): + return _error_response("amount_cents must be an integer", 400) + if amount_cents <= 0: + return _error_response("amount_cents must be positive", 400) + + currency = (body.get("currency") or "EGP").upper() + description = body.get("description") or "" + product_ref = body.get("product_ref") or "" + integration_id = str(body.get("integration_id") or default_integration) + + user = request.env.user + partner = user.partner_id + + # Create our own tracking row *before* calling Paymob so we never + # lose orders to partial failures. + order = request.env["encoach.paymob.order"].sudo().create({ + "reference": f"enc-{user.id}-{int(fields.Datetime.now().timestamp())}", + "user_id": user.id, + "partner_id": partner.id if partner else False, + "amount_cents": amount_cents, + "currency": currency, + "description": description, + "product_ref": product_ref, + "integration_id": integration_id, + "state": "draft", + }) + + # Step 1 — authenticate with the merchant API key. + auth = _paymob_post("/auth/tokens", {"api_key": api_key}) + auth_token = auth.get("token") + if not auth_token: + raise RuntimeError("Paymob auth returned no token") + + # Step 2 — register the order with Paymob. + order_payload = { + "auth_token": auth_token, + "delivery_needed": False, + "amount_cents": amount_cents, + "currency": currency, + "items": [], + "merchant_order_id": order.reference, + } + paymob_order = _paymob_post("/ecommerce/orders", order_payload) + paymob_order_id = str(paymob_order.get("id") or "") + if not paymob_order_id: + raise RuntimeError("Paymob order registration returned no id") + + # Step 3 — generate a payment key. + billing = { + "first_name": (user.name or "Guest").split(" ")[0][:50] or "Guest", + "last_name": ( + " ".join((user.name or "User").split(" ")[1:])[:50] or "User" + ), + "email": user.email or "no-reply@encoach.invalid", + "phone_number": partner.phone or partner.mobile or "+201000000000", + "apartment": "NA", "floor": "NA", "street": "NA", + "building": "NA", "shipping_method": "NA", "postal_code": "NA", + "city": "NA", "country": "EG", "state": "NA", + } + pk_payload = { + "auth_token": auth_token, + "amount_cents": amount_cents, + "currency": currency, + "order_id": paymob_order_id, + "billing_data": billing, + "integration_id": int(integration_id), + "lock_order_when_paid": True, + } + pk = _paymob_post("/acceptance/payment_keys", pk_payload) + payment_key = pk.get("token") + if not payment_key: + raise RuntimeError("Paymob payment_keys returned no token") + + iframe_url = ( + f"https://accept.paymob.com/api/acceptance/iframes/" + f"{iframe_id}?payment_token={payment_key}" + ) + + order.write({ + "paymob_order_id": paymob_order_id, + "payment_key": payment_key, + "state": "initiated", + }) + + return _json_response({ + "order_id": order.id, + "reference": order.reference, + "paymob_order_id": paymob_order_id, + "payment_key": payment_key, + "iframe_url": iframe_url, + }) + except Exception as e: + _logger.exception("paymob checkout failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/payments/paymob/webhook + # + # Paymob delivers the full transaction JSON with an ``hmac`` query-string + # parameter. We verify the HMAC over a canonical subset of fields and + # then update the matching order. + # ------------------------------------------------------------------ + @http.route( + "/api/payments/paymob/webhook", + type="http", auth="public", methods=["POST"], csrf=False, + ) + def webhook(self, **kw): + try: + secret = _config("hmac_secret") + if not secret: + _logger.error("paymob webhook: hmac secret not configured") + return Response(status=503) + + received_hmac = (kw.get("hmac") or "").strip() + if not received_hmac: + return Response(status=400) + + raw = (request.httprequest.data or b"") + try: + payload = json.loads(raw.decode("utf-8") or "{}") + except Exception: + return Response(status=400) + + # Paymob's payload envelope is: + # { "type": "TRANSACTION", "obj": { ...fields... } } + inner = payload.get("obj") or payload + + expected_hmac = _compute_hmac(inner, secret) + if not hmac.compare_digest(expected_hmac, received_hmac): + _logger.warning( + "paymob webhook: HMAC mismatch (expected=%s… got=%s…)", + expected_hmac[:16], received_hmac[:16], + ) + return Response(status=401) + + merchant_order_ref = ( + _get_nested(inner, "order.merchant_order_id") + or inner.get("merchant_order_id") + or "" + ) + paymob_order_id = str(_get_nested(inner, "order.id") or "") + success = bool(inner.get("success")) + + Order = request.env["encoach.paymob.order"].sudo() + order = False + if merchant_order_ref: + order = Order.search([("reference", "=", merchant_order_ref)], limit=1) + if not order and paymob_order_id: + order = Order.search([("paymob_order_id", "=", paymob_order_id)], limit=1) + if not order: + _logger.warning( + "paymob webhook: no matching order for ref=%s paymob=%s", + merchant_order_ref, paymob_order_id, + ) + # Return 200 so Paymob doesn't retry indefinitely — the event + # will still be visible in their dashboard. + return Response(status=200) + + if order.state == "paid": + # Idempotent: duplicate delivery. + return Response(status=200) + + payload_json = json.dumps(inner) + if success: + order.mark_paid(payload_json, received_hmac) + else: + order.mark_failed(payload_json, received_hmac) + + return Response(status=200) + except Exception: + _logger.exception("paymob webhook handler crashed") + return Response(status=500) + + # ------------------------------------------------------------------ + # GET /api/payments/paymob/orders — list my orders + # ------------------------------------------------------------------ + @http.route( + "/api/payments/paymob/orders", + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def my_orders(self, **kw): + try: + Order = request.env["encoach.paymob.order"].sudo() + domain = [("user_id", "=", request.env.user.id)] + rows = Order.search(domain, order="create_date desc", limit=200) + items = [{ + "id": r.id, + "reference": r.reference, + "paymob_order_id": r.paymob_order_id or "", + "amount_cents": r.amount_cents, + "currency": r.currency, + "description": r.description or "", + "product_ref": r.product_ref or "", + "state": r.state, + "create_date": fields.Datetime.to_string(r.create_date) if r.create_date else None, + "last_event_at": ( + fields.Datetime.to_string(r.last_event_at) if r.last_event_at else None + ), + } for r in rows] + return _json_response({ + "items": items, + "data": items, + "total": len(items), + }) + except Exception as e: + _logger.exception("paymob my_orders failed") + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/platform_settings.py b/backend/custom_addons/encoach_lms_api/controllers/platform_settings.py new file mode 100644 index 00000000..9df46372 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/platform_settings.py @@ -0,0 +1,232 @@ +"""Platform settings endpoints powering `/admin/settings-platform`. + +Exposes three groups: + * Codes (backed by `encoach.code`) + * Packages (simple persisted list in `ir.config_parameter`) + * Grading config (min / max / increment, in `ir.config_parameter`) +""" +import json +import logging +import uuid + +from odoo import http, fields as odoo_fields +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + +_PKG_PARAM = 'encoach.platform.packages' +_GRADE_PARAM = 'encoach.platform.grading' +_DEFAULT_PACKAGES = [ + {'id': 1, 'name': 'IELTS Starter', 'price': 99, 'duration': '1 month', 'discount': 0}, + {'id': 2, 'name': 'IELTS Pro', 'price': 249, 'duration': '3 months', 'discount': 15}, + {'id': 3, 'name': 'Corporate Bundle','price': 1999, 'duration': '12 months','discount': 25}, +] +_DEFAULT_GRADING = {'min_score': 0, 'max_score': 9, 'increment': 0.5} + + +def _ser_code(c): + return { + 'id': c.id, + 'code': c.code or '', + 'code_type': c.code_type or 'individual', + 'user_type': c.user_type or 'student', + 'max_uses': c.max_uses or 1, + 'uses': c.uses or 0, + 'used': bool(c.uses and c.max_uses and c.uses >= c.max_uses), + 'expiry_date': c.expiry_date.isoformat() if c.expiry_date else '', + 'created': c.create_date.isoformat() if c.create_date else '', + 'entity_id': c.entity_id.id if c.entity_id else None, + } + + +class PlatformSettingsController(http.Controller): + + # ── Codes ──────────────────────────────────────────────────────────── + @http.route('/api/codes', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_codes(self, **kw): + try: + Code = request.env['encoach.code'].sudo() + domain = [] + ct = kw.get('code_type') + if ct and ct != 'all': + domain.append(('code_type', '=', ct)) + used = kw.get('used') + try: + size = min(int(kw.get('size') or 100), 500) + except (TypeError, ValueError): + size = 100 + recs = Code.search(domain, limit=size, order='id desc') + items = [_ser_code(c) for c in recs] + if used in ('true', 'false'): + want = used == 'true' + items = [i for i in items if i['used'] == want] + return _json_response({ + 'data': items, 'items': items, 'total': len(items), + }) + except Exception as e: + _logger.exception('list_codes') + return _error_response(str(e), 500) + + @http.route('/api/codes/generate', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def generate_codes(self, **kw): + """Body: { count?: int, code_type?: 'individual'|'corporate', + user_type?: 'student'|'teacher'|'corporate', + max_uses?: int, expiry_date?: ISO-str }""" + try: + body = _get_json_body() + count = max(1, min(int(body.get('count') or 1), 200)) + vals_common = { + 'creator_id': request.env.user.id, + 'code_type': body.get('code_type') or 'individual', + 'user_type': body.get('user_type') or 'student', + 'max_uses': int(body.get('max_uses') or 1), + } + if body.get('expiry_date'): + vals_common['expiry_date'] = body['expiry_date'] + if body.get('entity_id'): + vals_common['entity_id'] = int(body['entity_id']) + + created = [] + for _ in range(count): + vals = dict(vals_common, code=uuid.uuid4().hex[:8].upper()) + rec = request.env['encoach.code'].sudo().create(vals) + created.append(_ser_code(rec)) + return _json_response({'data': created, 'count': len(created)}) + except Exception as e: + _logger.exception('generate_codes') + return _error_response(str(e), 500) + + @http.route('/api/codes/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_code(self, cid, **kw): + try: + rec = request.env['encoach.code'].sudo().browse(cid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_code') + return _error_response(str(e), 500) + + # ── Packages (ir.config_parameter JSON blob) ───────────────────────── + def _read_packages(self): + Param = request.env['ir.config_parameter'].sudo() + raw = Param.get_param(_PKG_PARAM) + if not raw: + return list(_DEFAULT_PACKAGES) + try: + return json.loads(raw) + except Exception: + return list(_DEFAULT_PACKAGES) + + def _write_packages(self, packages): + request.env['ir.config_parameter'].sudo().set_param( + _PKG_PARAM, json.dumps(packages)) + + @http.route('/api/packages', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_packages(self, **kw): + pkgs = self._read_packages() + return _json_response({'data': pkgs, 'items': pkgs, 'total': len(pkgs)}) + + @http.route('/api/packages', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_package(self, **kw): + try: + body = _get_json_body() + pkgs = self._read_packages() + next_id = max((p.get('id', 0) for p in pkgs), default=0) + 1 + pkg = { + 'id': next_id, + 'name': body.get('name') or f'Package {next_id}', + 'price': float(body.get('price') or 0), + 'duration': body.get('duration') or '1 month', + 'discount': float(body.get('discount') or 0), + } + pkgs.append(pkg) + self._write_packages(pkgs) + return _json_response(pkg) + except Exception as e: + _logger.exception('create_package') + return _error_response(str(e), 500) + + @http.route('/api/packages/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_package(self, pid, **kw): + try: + body = _get_json_body() + pkgs = self._read_packages() + for p in pkgs: + if p.get('id') == pid: + for k in ('name', 'duration'): + if k in body: + p[k] = body[k] + for k in ('price', 'discount'): + if k in body: + p[k] = float(body[k]) + self._write_packages(pkgs) + return _json_response(p) + return _error_response('Not found', 404) + except Exception as e: + _logger.exception('update_package') + return _error_response(str(e), 500) + + @http.route('/api/packages/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_package(self, pid, **kw): + pkgs = self._read_packages() + new_pkgs = [p for p in pkgs if p.get('id') != pid] + self._write_packages(new_pkgs) + return _json_response({'success': True}) + + # ── Grading (ir.config_parameter JSON blob) ─────────────────────────── + @http.route('/api/grading-config', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def get_grading(self, **kw): + Param = request.env['ir.config_parameter'].sudo() + raw = Param.get_param(_GRADE_PARAM) + try: + cfg = json.loads(raw) if raw else dict(_DEFAULT_GRADING) + except Exception: + cfg = dict(_DEFAULT_GRADING) + return _json_response({'data': cfg, **cfg}) + + @http.route('/api/grading-config', type='http', auth='public', + methods=['PATCH', 'PUT', 'POST'], csrf=False) + @jwt_required + def set_grading(self, **kw): + try: + body = _get_json_body() + Param = request.env['ir.config_parameter'].sudo() + raw = Param.get_param(_GRADE_PARAM) + cfg = dict(_DEFAULT_GRADING) + if raw: + try: + cfg.update(json.loads(raw)) + except Exception: + pass + for k in ('min_score', 'max_score'): + if k in body: + cfg[k] = int(body[k]) + if 'increment' in body: + cfg['increment'] = float(body['increment']) + if cfg['min_score'] >= cfg['max_score']: + return _error_response('min_score must be less than max_score', 400) + Param.set_param(_GRADE_PARAM, json.dumps(cfg)) + return _json_response({'data': cfg, **cfg}) + except Exception as e: + _logger.exception('set_grading') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/reports.py b/backend/custom_addons/encoach_lms_api/controllers/reports.py new file mode 100644 index 00000000..953c1a69 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/reports.py @@ -0,0 +1,489 @@ +"""Reports API. + +Serves the admin "Reports" section in the sidebar: + * Student Performance -> /api/reports/student-performance + * Stats Corporate -> /api/reports/stats-corporate + * Record -> /api/reports/record + +All endpoints aggregate from ``encoach.student.attempt`` (exam attempts with +per-skill band scores and CEFR level) and its surrounding records +(``res.users`` for student names, ``encoach.entity`` for corporate context, +``encoach.exam.custom`` for exam metadata). + +A single attempt is "reportable" when it has a completed/scored/released +status AND at least one non-null band. Everything else is ignored so the +Reports pages never surface misleading zeros for in-progress work. +""" +import logging +from collections import defaultdict +from datetime import datetime, timedelta + +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _paginate, +) +from odoo.addons.encoach_api.utils.cache import get as _cache_get, put as _cache_put + +_logger = logging.getLogger(__name__) + +REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released') + +from odoo.addons.encoach_ai.services.cefr_mapper import ( + band_to_cefr as _cefr_code_for_band, + normalize_cefr as _normalize_cefr, +) + + +def _cefr_for_band(band): + """Return the display-form CEFR label (e.g. 'B2') for an IELTS band.""" + code = _cefr_code_for_band(band) + if code is None: + return None + return _normalize_cefr(code) or code + + +def _duration_minutes(att): + start = att.started_at + end = None + if hasattr(att, 'completed_at'): + end = att.completed_at + if not end and hasattr(att, 'finished_at'): + end = att.finished_at + if not start or not end: + return None + return int(max(0, (end - start).total_seconds() // 60)) + + +def _attempt_completed_at(att): + if hasattr(att, 'completed_at') and att.completed_at: + return att.completed_at + if hasattr(att, 'finished_at') and att.finished_at: + return att.finished_at + return None + + +def _build_attempt_domain(kw, reportable=True): + """Common filter reader used by all three endpoints.""" + domain = [] + if reportable: + domain.append(('status', 'in', list(REPORTABLE_STATUSES))) + entity_id = kw.get('entity_id') + if entity_id: + try: + domain.append(('entity_id', '=', int(entity_id))) + except (TypeError, ValueError): + pass + user_id = kw.get('user_id') or kw.get('student_id') + if user_id: + try: + domain.append(('student_id', '=', int(user_id))) + except (TypeError, ValueError): + pass + since = kw.get('since') + if since: + try: + domain.append(('started_at', '>=', since)) + except Exception: + pass + period = (kw.get('period') or '').lower() + if period in ('day', 'week', 'month'): + delta = {'day': 1, 'week': 7, 'month': 30}[period] + cutoff = datetime.now() - timedelta(days=delta) + domain.append(('started_at', '>=', cutoff)) + return domain + + +class ReportsController(http.Controller): + + # ── 1. Student Performance ─────────────────────────────────────── + + @http.route('/api/reports/student-performance', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def student_performance(self, **kw): + """Return one row per student with averaged bands per skill. + + Query params: ``entity_id``, ``level`` (CEFR code, case-insensitive), + ``search`` (matches student name/login), ``since`` (ISO date). + """ + try: + Att = request.env['encoach.student.attempt'].sudo() + domain = _build_attempt_domain(kw) + attempts = Att.search(domain, order='started_at desc') + + per_student = defaultdict(lambda: { + 'attempts': [], + 'reading_sum': 0.0, 'reading_n': 0, + 'listening_sum': 0.0, 'listening_n': 0, + 'writing_sum': 0.0, 'writing_n': 0, + 'speaking_sum': 0.0, 'speaking_n': 0, + 'overall_sum': 0.0, 'overall_n': 0, + 'last_at': None, 'last_cefr': None, + 'entity_id': None, 'entity_name': None, + }) + + def _accum(agg, key, value): + if value is not None and value > 0: + agg[f'{key}_sum'] += float(value) + agg[f'{key}_n'] += 1 + + for att in attempts: + if not att.student_id: + continue + agg = per_student[att.student_id.id] + agg['attempts'].append(att.id) + _accum(agg, 'reading', att.reading_band) + _accum(agg, 'listening', att.listening_band) + _accum(agg, 'writing', att.writing_band) + _accum(agg, 'speaking', att.speaking_band) + _accum(agg, 'overall', att.overall_band) + ct = _attempt_completed_at(att) or att.started_at + if ct and (agg['last_at'] is None or ct > agg['last_at']): + agg['last_at'] = ct + agg['last_cefr'] = _normalize_cefr(att.cefr_level) \ + or _cefr_for_band(att.overall_band) + if att.entity_id and not agg['entity_id']: + agg['entity_id'] = att.entity_id.id + agg['entity_name'] = att.entity_id.name + + search_lc = (kw.get('search') or '').strip().lower() + level_filter = (kw.get('level') or '').strip().upper().replace('-', '_') + + rows = [] + for student_id, agg in per_student.items(): + user = request.env['res.users'].sudo().browse(student_id) + if not user.exists(): + continue + name = user.name or user.login or f'User #{student_id}' + if search_lc and search_lc not in (name or '').lower() \ + and search_lc not in (user.login or '').lower(): + continue + + def _avg(key): + n = agg[f'{key}_n'] + if not n: + return None + return round(agg[f'{key}_sum'] / n, 2) + + overall = _avg('overall') + if overall is None: + skill_avgs = [v for v in ( + _avg('reading'), _avg('listening'), + _avg('writing'), _avg('speaking'), + ) if v is not None] + overall = round(sum(skill_avgs) / len(skill_avgs), 2) \ + if skill_avgs else None + + level = agg['last_cefr'] or _cefr_for_band(overall) + if level_filter and (level or '').upper().replace('-', '_') \ + != level_filter: + continue + + rows.append({ + 'student_id': student_id, + 'student_name': name, + 'login': user.login or '', + 'entity_id': agg['entity_id'], + 'entity_name': agg['entity_name'] or '', + 'reading': _avg('reading'), + 'listening': _avg('listening'), + 'writing': _avg('writing'), + 'speaking': _avg('speaking'), + 'overall': overall, + 'level': level, + 'attempts_count': len(agg['attempts']), + 'last_attempt_at': agg['last_at'].isoformat() + if agg['last_at'] else None, + }) + + rows.sort(key=lambda r: (-(r['overall'] or 0.0), + r['student_name'].lower())) + + return _json_response({ + 'items': rows, + 'total': len(rows), + }) + except Exception as e: + _logger.exception('student-performance report failed') + return _error_response(str(e), 500) + + # ── 2. Stats Corporate ─────────────────────────────────────────── + + @http.route('/api/reports/stats-corporate', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def stats_corporate(self, **kw): + """Corporate rollups: avg by module, monthly trend, level distribution, + entity comparison. + + Query params: ``entity_id``, ``threshold`` (0..100, minimum band*10 to + include), ``months`` (trend horizon; default 6). + """ + try: + # 30s TTL cache: the stats dashboard is pinged from multiple widgets + # on every page view. Invalidated implicitly once the TTL expires; + # write-heavy endpoints (exam release, attempt score commit) can + # call ``cache.invalidate("reports.stats_corporate")`` if we ever + # need stronger consistency. + cache_key = ( + "reports.stats_corporate:" + f"entity={kw.get('entity_id') or ''};" + f"user={kw.get('user_id') or kw.get('student_id') or ''};" + f"thr={kw.get('threshold') or ''};" + f"months={kw.get('months') or ''};" + f"period={kw.get('period') or ''}" + ) + cached = _cache_get(cache_key) + if cached is not None: + return _json_response(cached) + + Att = request.env['encoach.student.attempt'].sudo() + domain = _build_attempt_domain(kw) + + try: + threshold = float(kw.get('threshold') or 0) + except (TypeError, ValueError): + threshold = 0.0 + if threshold > 0: + domain.append(('overall_band', '>=', threshold / 10.0)) + + # --- Per-skill average via SQL aggregation ------------------ + # Previously each of the four skill averages required pulling the + # full attempt rowset into Python and summing per record. This now + # runs as a single ``read_group`` per skill (avg in SQL), dropping + # the hot path from O(N) Python iterations to a constant number of + # round-trips regardless of attempt volume. + by_module = [] + skill_attempts_considered = 0 + for display, fname in ( + ('Reading', 'reading_band'), + ('Listening', 'listening_band'), + ('Writing', 'writing_band'), + ('Speaking', 'speaking_band'), + ): + agg_domain = domain + [(fname, '>', 0)] + rows = Att.read_group( + agg_domain, + fields=[f'{fname}:avg'], + groupby=[], + ) + if rows: + row = rows[0] + avg = row.get(fname) or 0 + n = row.get('__count') or Att.search_count(agg_domain) + else: + avg, n = 0, 0 + by_module.append({ + 'module': display, + 'score': round(avg * 10, 1) if n else 0, + 'n': n, + }) + skill_attempts_considered = max(skill_attempts_considered, n) + + # --- Monthly trend via read_group by month ------------------ + try: + months = max(1, min(24, int(kw.get('months') or 6))) + except (TypeError, ValueError): + months = 6 + trend_domain = domain + [('overall_band', '!=', False)] + month_rows = Att.read_group( + trend_domain, + fields=['overall_band:avg'], + groupby=['started_at:month'], + orderby='started_at:month asc', + ) + month_buckets = {} + for row in month_rows: + label = row.get('started_at:month') or row.get('started_at_month') + if not label: + continue + try: + ref = datetime.strptime(label, '%B %Y') + except ValueError: + continue + key = ref.strftime('%Y-%m') + month_buckets[key] = (row.get('overall_band') or 0.0, + row.get('__count') or 0) + + trend = [] + today = datetime.now().replace(day=1) + for i in range(months - 1, -1, -1): + ref = (today - timedelta(days=31 * i)).replace(day=1) + key = ref.strftime('%Y-%m') + avg_val, n = month_buckets.get(key, (0.0, 0)) + trend.append({ + 'month': ref.strftime('%b'), + 'avg': round(avg_val * 10, 1) if n else 0, + 'period': key, + 'n': n, + }) + + # --- CEFR distribution via read_group by cefr_level --------- + dist_rows = Att.read_group( + domain + [('cefr_level', '!=', False)], + fields=[], + groupby=['cefr_level'], + ) + level_buckets = {} + for row in dist_rows: + label = _normalize_cefr(row.get('cefr_level')) + if label: + level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0) + palette = { + 'Pre-A1': 'hsl(0, 0%, 55%)', + 'A1': 'hsl(0, 72%, 51%)', + 'A2': 'hsl(38, 92%, 50%)', + 'B1': 'hsl(199, 89%, 48%)', + 'B2': 'hsl(243, 75%, 59%)', + 'C1': 'hsl(142, 71%, 45%)', + 'C2': 'hsl(280, 65%, 50%)', + } + distribution = [ + {'name': lvl, 'value': level_buckets.get(lvl, 0), + 'color': palette[lvl]} + for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2') + if level_buckets.get(lvl) + ] or [ + {'name': lvl, 'value': 0, 'color': palette[lvl]} + for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2') + ] + + # --- Entity comparison via read_group by entity_id ---------- + entity_rows = Att.read_group( + domain + [('overall_band', '!=', False)], + fields=['overall_band:avg'], + groupby=['entity_id'], + ) + comparison = [] + for row in entity_rows: + entity = row.get('entity_id') or (None, 'Unassigned') + eid, ename = (entity[0], entity[1]) if isinstance(entity, (list, tuple)) else (None, 'Unassigned') + avg_val = row.get('overall_band') or 0 + comparison.append({ + 'entity_id': eid, + 'entity_name': ename or 'Unassigned', + 'avg': round(avg_val * 10, 1), + 'attempts': row.get('__count') or 0, + }) + comparison.sort(key=lambda r: -r['avg']) + + total_considered = Att.search_count(domain) + payload = { + 'by_module': by_module, + 'trend': trend, + 'distribution': distribution, + 'comparison': comparison, + 'meta': { + 'attempts_considered': total_considered, + 'threshold': threshold, + 'months': months, + }, + } + _cache_put(cache_key, payload, ttl_seconds=30) + return _json_response(payload) + except Exception as e: + _logger.exception('stats-corporate report failed') + return _error_response(str(e), 500) + + # ── 3. Record ──────────────────────────────────────────────────── + + @http.route('/api/reports/record', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def record(self, **kw): + """Per-user attempt history. + + Query params: ``user_id`` (required for focused view; omit to see all), + ``entity_id``, ``period`` (day|week|month), ``status``, pagination. + """ + try: + Att = request.env['encoach.student.attempt'].sudo() + domain = _build_attempt_domain(kw, reportable=False) + if kw.get('status'): + domain.append(('status', '=', kw['status'])) + + offset, limit, page = _paginate(kw) + total = Att.search_count(domain) + attempts = Att.search(domain, offset=offset, limit=limit, + order='started_at desc') + + items = [] + for att in attempts: + exam = att.exam_id + exam_title = '' + if exam: + # encoach.exam.custom labels its record with `title`, + # not `name`. Fall back to display_name if the field + # is ever renamed upstream. + exam_title = getattr(exam, 'title', None) \ + or getattr(exam, 'display_name', '') or '' + assignment = request.env['encoach.exam.assignment'].sudo() \ + .search([('exam_id', '=', exam.id), + ('student_id', '=', att.student_id.id)], + limit=1) if exam and att.student_id else None + duration_min = _duration_minutes(att) + ct = _attempt_completed_at(att) or att.started_at + items.append({ + 'id': att.id, + 'student_id': att.student_id.id or None, + 'student_name': att.student_id.name if att.student_id else '', + 'assignment': exam_title, + 'assignment_id': assignment.id if assignment else None, + 'exam': exam_title, + 'exam_code': f'EX-{exam.id:03d}' if exam else '', + 'exam_id': exam.id or None, + 'entity_id': att.entity_id.id or None, + 'entity_name': att.entity_id.name if att.entity_id else '', + 'date': ct.strftime('%Y-%m-%d') if ct else None, + 'started_at': att.started_at.isoformat() + if att.started_at else None, + 'completed_at': (_attempt_completed_at(att).isoformat() + if _attempt_completed_at(att) else None), + 'score': att.overall_band if att.overall_band else None, + 'duration_min': duration_min, + 'duration': f'{duration_min} min' if duration_min is not None + else '—', + 'status': att.status or 'in_progress', + 'status_label': (att.status or 'in_progress').replace('_', ' ').title(), + 'cefr_level': _normalize_cefr(att.cefr_level) + or _cefr_for_band(att.overall_band), + }) + + return _json_response({ + 'items': items, + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('record report failed') + return _error_response(str(e), 500) + + # ── Shared picker: entities and students for report filters ────── + + @http.route('/api/reports/filters', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def filters(self, **kw): + """Lightweight list of entities + students/users we have attempts for. + Used by the filter dropdowns on all three Reports pages.""" + try: + Ent = request.env['encoach.entity'].sudo() + entities = [{'id': e.id, 'name': e.name or ''} + for e in Ent.search([], order='name')] + + Att = request.env['encoach.student.attempt'].sudo() + att_students = Att.search([]).mapped('student_id') + students = [{'id': u.id, 'name': u.name or u.login or '', + 'login': u.login or ''} + for u in att_students.sorted('name') + if u and u.id > 0] + + return _json_response({ + 'entities': entities, + 'students': students, + }) + except Exception as e: + _logger.exception('reports filters failed') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/resources.py b/backend/custom_addons/encoach_lms_api/controllers/resources.py new file mode 100644 index 00000000..b205552c --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/resources.py @@ -0,0 +1,286 @@ +import base64 +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_resource(r): + tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag'] + objectives = r.learning_objective_ids if r.learning_objective_ids else r.env['encoach.learning.objective'] + return { + 'id': r.id, + 'name': r.name or '', + 'resource_type': r.type or 'document', + 'type': r.type or 'document', + 'subject_id': r.subject_id.id if r.subject_id else None, + 'subject_name': r.subject_id.name if r.subject_id else '', + 'domain_id': r.domain_id.id if r.domain_id else None, + 'domain_name': r.domain_id.name if r.domain_id else '', + 'topic_ids': r.topic_ids.ids if r.topic_ids else [], + 'topic_names': r.topic_ids.mapped('name') if r.topic_ids else [], + 'learning_objective_ids': objectives.ids, + 'learning_objective_names': objectives.mapped('name'), + 'tag_ids': tags.ids, + 'tag_names': tags.mapped('name'), + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags], + 'url': r.url or '', + 'has_file': bool(r.file), + 'difficulty': r.difficulty or '', + 'duration_minutes': r.duration_minutes or 0, + 'author_id': r.creator_id.id if r.creator_id else None, + 'author_name': r.creator_id.name if r.creator_id else '', + 'review_status': r.review_status or 'approved', + 'cefr_level': r.cefr_level or '', + 'ai_generated': r.ai_generated, + 'course_count': r.course_count or 0, + 'created_at': r.create_date.isoformat() if r.create_date else '', + } + + +class ResourcesController(http.Controller): + + @http.route('/api/resources', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_resources(self, **kw): + try: + M = request.env['encoach.resource'].sudo() + domain = [] + if kw.get('subject_id'): + domain.append(('subject_id', '=', int(kw['subject_id']))) + if kw.get('topic_id'): + domain.append(('topic_ids', 'in', [int(kw['topic_id'])])) + if kw.get('tag_id'): + domain.append(('tag_ids', 'in', [int(kw['tag_id'])])) + if kw.get('resource_type'): + domain.append(('type', '=', kw['resource_type'])) + if kw.get('review_status'): + domain.append(('review_status', '=', kw['review_status'])) + if kw.get('date_from'): + domain.append(('create_date', '>=', kw['date_from'])) + if kw.get('date_to'): + domain.append(('create_date', '<=', kw['date_to'])) + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_resource(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_resources') + return _error_response(str(e), 500) + + @http.route('/api/resources', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_resource(self, **kw): + try: + files = request.httprequest.files + f = files.get('file') + ct = request.httprequest.content_type or '' + body = {} + if 'application/json' in ct: + body = _get_json_body() + params = {**body, **kw} + + vals = {'name': params.get('name', f.filename if f else 'Resource')} + rtype = params.get('resource_type') or params.get('type') + if rtype: + vals['type'] = rtype + if params.get('subject_id'): + vals['subject_id'] = int(params['subject_id']) + if params.get('topic_id'): + vals['topic_ids'] = [(4, int(params['topic_id']))] + if params.get('topic_ids'): + ids = params['topic_ids'] + if isinstance(ids, str): + ids = [int(x) for x in ids.split(',') if x.strip()] + elif isinstance(ids, list): + ids = [int(x) for x in ids] + vals['topic_ids'] = [(6, 0, ids)] + if params.get('tag_ids'): + ids = params['tag_ids'] + if isinstance(ids, str): + ids = [int(x) for x in ids.split(',') if x.strip()] + elif isinstance(ids, list): + ids = [int(x) for x in ids] + vals['tag_ids'] = [(6, 0, ids)] + if params.get('domain_id'): + vals['domain_id'] = int(params['domain_id']) + if params.get('learning_objective_ids'): + ids = params['learning_objective_ids'] + if isinstance(ids, str): + ids = [int(x) for x in ids.split(',') if x.strip()] + elif isinstance(ids, list): + ids = [int(x) for x in ids] + vals['learning_objective_ids'] = [(6, 0, ids)] + if params.get('url'): + vals['url'] = params['url'] + if params.get('difficulty'): + vals['difficulty'] = params['difficulty'] + if params.get('cefr_level'): + vals['cefr_level'] = params['cefr_level'] + if f: + vals['file'] = base64.b64encode(f.read()) + rec = request.env['encoach.resource'].sudo().create(vals) + return _json_response({'data': _ser_resource(rec)}) + except Exception as e: + _logger.exception('create_resource') + return _error_response(str(e), 500) + + @http.route('/api/resources/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_resource(self, rid, **kw): + try: + rec = request.env['encoach.resource'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'url', 'review_status', 'difficulty', 'cefr_level'): + if k in body: + vals[k] = body[k] + if 'resource_type' in body or 'type' in body: + vals['type'] = body.get('resource_type') or body.get('type') + if 'subject_id' in body: + vals['subject_id'] = int(body['subject_id']) if body['subject_id'] else False + if 'tag_ids' in body: + ids = body['tag_ids'] + vals['tag_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)] + if 'domain_id' in body: + vals['domain_id'] = int(body['domain_id']) if body['domain_id'] else False + if 'topic_ids' in body: + ids = body['topic_ids'] + vals['topic_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)] + elif 'topic_id' in body: + vals['topic_ids'] = [(4, int(body['topic_id']))] if body['topic_id'] else [(5,)] + if 'learning_objective_ids' in body: + ids = body['learning_objective_ids'] + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)] + if vals: + rec.write(vals) + return _json_response({'data': _ser_resource(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_resource(self, rid, **kw): + try: + rec = request.env['encoach.resource'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources//complete', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_complete(self, rid, **kw): + try: + return _json_response({'data': {'id': rid, 'completed': True}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources//rate', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def rate_resource(self, rid, **kw): + try: + body = _get_json_body() + return _json_response({'success': True, 'rating': body.get('rating', 0)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources//download', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def download_resource(self, rid, **kw): + try: + rec = request.env['encoach.resource'].sudo().browse(rid) + if not rec.exists() or not rec.file: + return _error_response('No file', 404) + import mimetypes + ext = (rec.name or '').rsplit('.', 1)[-1].lower() if '.' in (rec.name or '') else '' + mime = mimetypes.types_map.get(f'.{ext}', 'application/octet-stream') + data = base64.b64decode(rec.file) + return request.make_response(data, [ + ('Content-Type', mime), + ('Content-Disposition', f'attachment; filename="{rec.name}"'), + ('Content-Length', str(len(data))), + ]) + except Exception as e: + _logger.exception('download_resource') + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # Resource Tags CRUD + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/resource-tags', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_tags(self, **kw): + try: + Tag = request.env['encoach.resource.tag'].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + recs = Tag.search(domain, order='name') + return _json_response({ + 'data': [t.to_api_dict() for t in recs], + 'items': [t.to_api_dict() for t in recs], + }) + except Exception as e: + _logger.exception('list_tags') + return _error_response(str(e), 500) + + @http.route('/api/resource-tags', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_tag(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '').strip()} + if not vals['name']: + return _error_response('name is required', 400) + if body.get('color'): + vals['color'] = body['color'] + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.resource.tag'].sudo().create(vals) + return _json_response({'data': rec.to_api_dict()}, 201) + except Exception as e: + _logger.exception('create_tag') + return _error_response(str(e), 500) + + @http.route('/api/resource-tags/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_tag(self, tid, **kw): + try: + rec = request.env['encoach.resource.tag'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Tag not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'color', 'description'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response({'data': rec.to_api_dict()}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resource-tags/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_tag(self, tid, **kw): + try: + rec = request.env['encoach.resource.tag'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/stats.py b/backend/custom_addons/encoach_lms_api/controllers/stats.py new file mode 100644 index 00000000..76f93f08 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/stats.py @@ -0,0 +1,112 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, +) + +_logger = logging.getLogger(__name__) + + +class StatsController(http.Controller): + + @http.route('/api/sessions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_sessions(self, **kw): + try: + return _json_response([]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/stats', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_stats(self, **kw): + try: + env = request.env + course_count = env['op.course'].sudo().search_count([]) + student_count = env['op.student'].sudo().search_count([]) + faculty_count = env['op.faculty'].sudo().search_count([]) + batch_count = env['op.batch'].sudo().search_count([]) + return _json_response([{ + 'label': 'courses', 'value': course_count, + }, { + 'label': 'students', 'value': student_count, + }, { + 'label': 'teachers', 'value': faculty_count, + }, { + 'label': 'batches', 'value': batch_count, + }]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/statistical', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_statistical(self, **kw): + try: + env = request.env + return _json_response({ + 'total_students': env['op.student'].sudo().search_count([]), + 'total_courses': env['op.course'].sudo().search_count([]), + 'total_teachers': env['op.faculty'].sudo().search_count([]), + 'total_batches': env['op.batch'].sudo().search_count([]), + 'exam_count': 0, + 'pass_rate': 0, + 'avg_score': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/stats/performance', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_performance(self, **kw): + try: + return _json_response([]) + except Exception as e: + return _error_response(str(e), 500) + + # ── Analytics ──────────────────────────────────────────────────── + + @http.route('/api/analytics/student', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_student(self, **kw): + try: + return _json_response({ + 'total_courses': 0, + 'completed_courses': 0, + 'average_score': 0, + 'attendance_rate': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/analytics/class', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_class(self, **kw): + try: + return _json_response({ + 'total_students': 0, + 'average_score': 0, + 'pass_rate': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/analytics/subject', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_subject(self, **kw): + try: + return _json_response({ + 'subjects': [], + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/analytics/content-gaps', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_content_gaps(self, **kw): + try: + return _json_response({ + 'gaps': [], + }) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/student_leave.py b/backend/custom_addons/encoach_lms_api/controllers/student_leave.py new file mode 100644 index 00000000..c4f8a594 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/student_leave.py @@ -0,0 +1,144 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_leave(r): + return { + 'id': r.id, + 'request_number': r.request_number or '', + 'student_id': r.student_id.id if r.student_id else 0, + 'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '', + 'leave_type': r.leave_type_id.name if r.leave_type_id else '', + 'leave_type_id': r.leave_type_id.id if r.leave_type_id else 0, + 'start_date': str(r.start_date) if r.start_date else '', + 'end_date': str(r.end_date) if r.end_date else '', + 'duration': r.duration or 0, + 'description': r.description or '', + 'state': r.state or 'draft', + 'approve_date': str(r.approve_date) if r.approve_date else None, + } + + +class StudentLeaveController(http.Controller): + + @http.route('/api/student-leaves', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_leaves(self, **kw): + try: + M = request.env['encoach.student.leave'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='create_date desc') + items = [_ser_leave(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_leave(self, **kw): + try: + body = _get_json_body() + vals = { + 'start_date': body.get('start_date'), + 'end_date': body.get('end_date'), + } + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + if body.get('leave_type'): + vals['leave_type_id'] = int(body['leave_type']) + if body.get('leave_type_id'): + vals['leave_type_id'] = int(body['leave_type_id']) + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.student.leave'].sudo().create(vals) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('start_date', 'end_date', 'description'): + if k in body: + vals[k] = body[k] + if 'leave_type' in body: + vals['leave_type_id'] = int(body['leave_type']) + if vals: + rec.write(vals) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves//approve', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def approve_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import date + rec.write({'state': 'approve', 'approve_date': str(date.today()), 'approved_by': request.env.uid}) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves//reject', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def reject_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'refuse'}) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Leave Types ────────────────────────────────────────────────── + + @http.route('/api/student-leave-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_leave_types(self, **kw): + try: + M = request.env['encoach.student.leave.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{'id': r.id, 'name': r.name} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leave-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_leave_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.student.leave.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/student_progress.py b/backend/custom_addons/encoach_lms_api/controllers/student_progress.py new file mode 100644 index 00000000..ba623424 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/student_progress.py @@ -0,0 +1,215 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class StudentProgressController(http.Controller): + """ + Institutional "Student Progress" view. + + Aggregates per-student totals across the key OpenEduCat operational + records an admin cares about at a glance: + + - total_attendance → number of op.attendance.line rows (i.e. sessions + attended or at least registered for) + - total_attendance_present → subset where present=True (best-effort, some + OpenEduCat variants store this differently) + - total_assignment → number of op.assignment.sub.line rows + - total_marksheet_line → number of op.marksheet.line rows + - total_admissions → number of op.admission rows (if the student was + admitted through the admission workflow) + + The endpoint returns one row per op.student. Search (?q=) and batch + (?batch_id=) / course (?course_id=) filters are supported so the page + stays usable at scale. + """ + + @http.route('/api/student-progress', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_progress(self, **kw): + try: + env = request.env + Student = env['op.student'].sudo() if 'op.student' in env else None + if Student is None: + return _json_response({'items': [], 'data': [], 'total': 0, 'page': 1, 'size': 0}) + + offset, limit, page = _paginate(kw) + domain = [('active', '=', True)] if 'active' in Student._fields else [] + q = (kw.get('q') or '').strip() + if q: + domain += ['|', + ('partner_id.name', 'ilike', q), + ('gr_no', 'ilike', q)] + batch_id = kw.get('batch_id') + if batch_id: + try: + bid = int(batch_id) + if 'course_detail_ids' in Student._fields: + domain.append(('course_detail_ids.batch_id', '=', bid)) + except Exception: + pass + course_id = kw.get('course_id') + if course_id: + try: + cid = int(course_id) + if 'course_detail_ids' in Student._fields: + domain.append(('course_detail_ids.course_id', '=', cid)) + except Exception: + pass + + total = Student.search_count(domain) + students = Student.search(domain, offset=offset, limit=limit, order='id asc') + + AttLine = env['op.attendance.line'].sudo() if 'op.attendance.line' in env else None + AsgSub = env['op.assignment.sub.line'].sudo() if 'op.assignment.sub.line' in env else None + MsLine = env['op.marksheet.line'].sudo() if 'op.marksheet.line' in env else None + Admission = env['op.admission'].sudo() if 'op.admission' in env else None + + items = [] + for s in students: + sid = s.id + partner = s.partner_id if 'partner_id' in s._fields else False + student_name = partner.name if partner else (s.display_name or '') + + total_attendance = 0 + total_attendance_present = 0 + if AttLine is not None: + try: + total_attendance = AttLine.search_count([('student_id', '=', sid)]) + if 'present' in AttLine._fields: + total_attendance_present = AttLine.search_count( + [('student_id', '=', sid), ('present', '=', True)] + ) + elif 'attendance' in AttLine._fields: + total_attendance_present = AttLine.search_count( + [('student_id', '=', sid), ('attendance', '=', True)] + ) + except Exception: + pass + + total_assignment = 0 + if AsgSub is not None: + try: + total_assignment = AsgSub.search_count([('student_id', '=', sid)]) + except Exception: + pass + + total_marksheet_line = 0 + avg_marks = None + if MsLine is not None: + try: + lines = MsLine.search([('student_id', '=', sid)]) + total_marksheet_line = len(lines) + if lines and 'marks' in MsLine._fields: + marks = [float(l.marks or 0) for l in lines] + if marks: + avg_marks = round(sum(marks) / len(marks), 2) + except Exception: + pass + + total_admissions = 0 + if Admission is not None: + try: + total_admissions = Admission.search_count([('student_id', '=', sid)]) + except Exception: + pass + + attendance_rate = None + if total_attendance: + attendance_rate = round( + (total_attendance_present / total_attendance) * 100, 1 + ) + + items.append({ + 'id': sid, + 'student_id': sid, + 'student_name': student_name, + 'gr_no': getattr(s, 'gr_no', '') or '', + 'total_attendance': total_attendance, + 'total_attendance_present': total_attendance_present, + 'attendance_rate': attendance_rate, + 'total_assignment': total_assignment, + 'total_marksheet_line': total_marksheet_line, + 'avg_marks': avg_marks, + 'total_admissions': total_admissions, + }) + + return _json_response({ + 'items': items, + 'data': items, + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_progress') + return _error_response(str(e), 500) + + @http.route('/api/student-progress/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_progress(self, sid, **kw): + """Detailed per-student breakdown (lines, not just totals).""" + try: + env = request.env + if 'op.student' not in env: + return _error_response('op.student model not installed', 404) + student = env['op.student'].sudo().browse(sid) + if not student.exists(): + return _error_response('Student not found', 404) + + partner = student.partner_id if 'partner_id' in student._fields else False + out = { + 'id': student.id, + 'student_name': partner.name if partner else (student.display_name or ''), + 'gr_no': getattr(student, 'gr_no', '') or '', + 'attendance': [], + 'assignments': [], + 'marksheets': [], + } + + if 'op.attendance.line' in env: + AL = env['op.attendance.line'].sudo() + lines = AL.search([('student_id', '=', sid)], limit=100, order='id desc') + for l in lines: + present = None + if 'present' in AL._fields: + present = bool(l.present) + elif 'attendance' in AL._fields: + present = bool(l.attendance) + out['attendance'].append({ + 'id': l.id, + 'sheet': l.attendance_id.display_name if 'attendance_id' in l._fields and l.attendance_id else '', + 'present': present, + }) + + if 'op.assignment.sub.line' in env: + AS = env['op.assignment.sub.line'].sudo() + lines = AS.search([('student_id', '=', sid)], limit=100, order='id desc') + for l in lines: + out['assignments'].append({ + 'id': l.id, + 'assignment': l.assignment_id.display_name if 'assignment_id' in l._fields and l.assignment_id else '', + 'marks': float(getattr(l, 'marks', 0) or 0), + 'state': getattr(l, 'state', '') or '', + }) + + if 'op.marksheet.line' in env: + ML = env['op.marksheet.line'].sudo() + lines = ML.search([('student_id', '=', sid)], limit=100, order='id desc') + for l in lines: + out['marksheets'].append({ + 'id': l.id, + 'marksheet': l.marksheet_reg_id.display_name if 'marksheet_reg_id' in l._fields and l.marksheet_reg_id else '', + 'marks': float(getattr(l, 'marks', 0) or 0), + 'grade': getattr(l, 'grade', '') or '', + }) + + return _json_response(out) + except Exception as e: + _logger.exception('get_progress') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/tickets.py b/backend/custom_addons/encoach_lms_api/controllers/tickets.py new file mode 100644 index 00000000..b1b5b4b2 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/tickets.py @@ -0,0 +1,176 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + + +def _ser_ticket(t): + return { + 'id': t.id, + 'subject': t.subject or '', + 'description': t.description or '', + 'type': t.type or 'support', + 'status': t.status or 'open', + 'source': t.source or 'platform', + 'page_context': t.page_context or '', + 'reporter_id': t.reporter_id.id if t.reporter_id else None, + 'reporter_name': t.reporter_id.name if t.reporter_id else '', + 'assignee_id': t.assignee_id.id if t.assignee_id else None, + 'assignee_name': t.assignee_id.name if t.assignee_id else '', + 'entity_id': t.entity_id.id if t.entity_id else None, + 'entity_name': t.entity_id.name if t.entity_id else '', + 'created_at': t.create_date.isoformat() if t.create_date else '', + 'resolved_at': t.resolved_at.isoformat() if t.resolved_at else '', + } + + +class TicketsController(http.Controller): + + @http.route('/api/tickets', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_tickets(self, **kw): + try: + Ticket = request.env['encoach.ticket'].sudo() + domain = [] + status = kw.get('status') + if status and status != 'all': + domain.append(('status', '=', status)) + t = kw.get('type') + if t and t != 'all': + domain.append(('type', '=', t)) + src = kw.get('source') + if src and src != 'all': + domain.append(('source', '=', src)) + assignee = kw.get('assignee_id') + if assignee: + try: + domain.append(('assignee_id', '=', int(assignee))) + except (TypeError, ValueError): + pass + search = kw.get('search') or kw.get('q') + if search: + domain.append('|') + domain.append(('subject', 'ilike', search)) + domain.append(('description', 'ilike', search)) + + try: + size = min(int(kw.get('size') or 50), 200) + except (TypeError, ValueError): + size = 50 + try: + page = max(int(kw.get('page') or 1), 1) + except (TypeError, ValueError): + page = 1 + + total = Ticket.search_count(domain) + recs = Ticket.search(domain, limit=size, offset=(page - 1) * size) + items = [_ser_ticket(t) for t in recs] + return _json_response({ + 'data': items, + 'items': items, + 'total': total, + 'page': page, + 'size': size, + }) + except Exception as e: + _logger.exception('list_tickets') + return _error_response(str(e), 500) + + @http.route('/api/tickets', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_ticket(self, **kw): + try: + body = _get_json_body() + if not body.get('subject'): + return _error_response('subject is required', 400) + vals = { + 'subject': body['subject'], + 'description': body.get('description') or '', + 'type': body.get('type') or 'support', + 'status': body.get('status') or 'open', + 'source': body.get('source') or 'platform', + 'page_context': body.get('page_context') or '', + } + if body.get('assignee_id'): + vals['assignee_id'] = int(body['assignee_id']) + if body.get('entity_id'): + vals['entity_id'] = int(body['entity_id']) + # reporter defaults to current user via the model default. + rec = request.env['encoach.ticket'].sudo().create(vals) + return _json_response(_ser_ticket(rec)) + except Exception as e: + _logger.exception('create_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def get_ticket(self, tid, **kw): + try: + rec = request.env['encoach.ticket'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_ticket(rec)) + except Exception as e: + _logger.exception('get_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_ticket(self, tid, **kw): + try: + from odoo import fields as odoo_fields + body = _get_json_body() + rec = request.env['encoach.ticket'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + for k in ('subject', 'description', 'type', 'status', 'source', 'page_context'): + if k in body: + vals[k] = body[k] + if 'assignee_id' in body: + vals['assignee_id'] = int(body['assignee_id']) if body['assignee_id'] else False + if 'entity_id' in body: + vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False + # Auto-stamp resolved_at when transitioning to resolved/closed. + if vals.get('status') in ('resolved', 'closed') and not rec.resolved_at: + vals['resolved_at'] = odoo_fields.Datetime.now() + if vals: + rec.write(vals) + return _json_response(_ser_ticket(rec)) + except Exception as e: + _logger.exception('update_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_ticket(self, tid, **kw): + try: + rec = request.env['encoach.ticket'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/assignedToUser', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def assigned_to_user(self, **kw): + try: + uid = request.env.user.id + recs = request.env['encoach.ticket'].sudo().search([ + ('assignee_id', '=', uid), + ], limit=100) + items = [_ser_ticket(t) for t in recs] + return _json_response({'data': items, 'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('assigned_to_user') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/timetable.py b/backend/custom_addons/encoach_lms_api/controllers/timetable.py new file mode 100644 index 00000000..f2b7ba0a --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/timetable.py @@ -0,0 +1,75 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + + +def _ser_session(s): + timing = s.timing_id if hasattr(s, 'timing_id') and s.timing_id else None + return { + 'id': s.id, + 'course_id': s.course_id.id if hasattr(s, 'course_id') and s.course_id else 0, + 'course_name': s.course_id.name if hasattr(s, 'course_id') and s.course_id else '', + 'teacher_name': s.faculty_id.partner_id.name if hasattr(s, 'faculty_id') and s.faculty_id and s.faculty_id.partner_id else '', + 'batch_name': s.batch_id.name if hasattr(s, 'batch_id') and s.batch_id else '', + 'day': getattr(s, 'day', '') or '', + 'start_time': str(timing.hour) + ':' + str(int(timing.minute)).zfill(2) if timing and hasattr(timing, 'hour') else getattr(s, 'start_datetime', '') or '', + 'end_time': '', + 'room': getattr(s, 'classroom_id', False) and s.classroom_id.name or '', + } + + +class TimetableController(http.Controller): + + @http.route('/api/timetable', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_timetable(self, **kw): + try: + M = request.env['op.session'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + recs = M.search(domain, limit=200, order='id desc') + data = [_ser_session(r) for r in recs] + return _json_response({'items': data, 'data': data, 'total': len(data)}) + except Exception as e: + _logger.exception('list_timetable') + return _error_response(str(e), 500) + + @http.route('/api/timetable', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_timetable(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + if body.get('faculty_id'): + vals['faculty_id'] = int(body['faculty_id']) + if body.get('subject_id'): + vals['subject_id'] = int(body['subject_id']) + if body.get('day'): + vals['day'] = body['day'] + rec = request.env['op.session'].sudo().create(vals) + return _json_response(_ser_session(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/timetable/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_timetable(self, sid, **kw): + try: + rec = request.env['op.session'].sudo().browse(sid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/training.py b/backend/custom_addons/encoach_lms_api/controllers/training.py new file mode 100644 index 00000000..8523f095 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/training.py @@ -0,0 +1,372 @@ +"""Training endpoints: vocabulary + grammar library + per-user progress.""" +import logging +from odoo import http, fields as odoo_fields +from odoo.exceptions import ValidationError, UserError +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, +) + +_logger = logging.getLogger(__name__) + + +def _user_error_status(e): + """Translate Odoo validation errors into HTTP 400 instead of 500.""" + return 400 if isinstance(e, (ValidationError, UserError)) else 500 + + +# ── Serializers ───────────────────────────────────────────────────────── +def _ser_vocab(v, progress=None): + return { + 'id': v.id, + 'word': v.word or '', + 'meaning': v.meaning or '', + 'example_sentence': v.example_sentence or '', + 'level': v.level or 'B1', + 'part_of_speech': v.part_of_speech or 'noun', + 'category': v.category or 'general', + 'active': v.active, + 'learners_count': v.learners_count or 0, + 'completion_count': v.completion_count or 0, + 'completed': bool(progress and progress.completed), + 'mastery': (progress.mastery if progress else 'learning'), + 'last_reviewed': (progress.last_reviewed.isoformat() if progress and progress.last_reviewed else ''), + } + + +def _ser_rule(r, progress=None): + return { + 'id': r.id, + 'name': r.name or '', + 'description': r.description or '', + 'example': r.example or '', + 'level': r.level or 'B1', + 'category': r.category or 'general', + 'active': r.active, + 'learners_count': r.learners_count or 0, + 'completion_count': r.completion_count or 0, + 'completed': bool(progress and progress.completed), + 'last_reviewed': (progress.last_reviewed.isoformat() if progress and progress.last_reviewed else ''), + } + + +def _domain_from_kw(kw, text_fields): + domain = [] + if kw.get('level') and kw['level'] != 'all': + domain.append(('level', '=', kw['level'])) + if kw.get('category') and kw['category'] != 'all': + domain.append(('category', '=', kw['category'])) + active = kw.get('active') + if active in ('true', 'false'): + domain.append(('active', '=', active == 'true')) + search = kw.get('search') or kw.get('q') + if search and text_fields: + terms = [(f, 'ilike', search) for f in text_fields] + if len(terms) > 1: + domain.extend(['|'] * (len(terms) - 1)) + domain.extend(terms) + return domain + + +def _pager(kw): + try: + size = min(int(kw.get('size') or 100), 500) + except (TypeError, ValueError): + size = 100 + try: + page = max(int(kw.get('page') or 1), 1) + except (TypeError, ValueError): + page = 1 + return size, page + + +# ── Vocabulary ────────────────────────────────────────────────────────── +class VocabularyController(http.Controller): + + @http.route('/api/training/vocabulary', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_vocab(self, **kw): + try: + Vocab = request.env['encoach.vocab.item'].sudo() + domain = _domain_from_kw(kw, ['word', 'meaning', 'category']) + size, page = _pager(kw) + total = Vocab.search_count(domain) + recs = Vocab.search(domain, limit=size, offset=(page - 1) * size) + + Progress = request.env['encoach.vocab.progress'].sudo() + uid = request.env.user.id + prog_map = { + p.vocab_id.id: p for p in Progress.search([ + ('user_id', '=', uid), + ('vocab_id', 'in', recs.ids), + ]) + } if recs else {} + + items = [_ser_vocab(v, prog_map.get(v.id)) for v in recs] + completed = sum(1 for i in items if i['completed']) + return _json_response({ + 'data': items, 'items': items, 'total': total, + 'summary': { + 'total': total, + 'completed': completed, + 'remaining': total - completed, + 'completion_rate': round((completed / total) * 100, 1) if total else 0.0, + }, + 'page': page, 'size': size, + }) + except Exception as e: + _logger.exception('list_vocab') + return _error_response(str(e), 500) + + @http.route('/api/training/vocabulary', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_vocab(self, **kw): + try: + body = _get_json_body() + if not body.get('word') or not body.get('meaning'): + return _error_response('word and meaning are required', 400) + vals = { + 'word': body['word'].strip(), + 'meaning': body['meaning'].strip(), + 'example_sentence': body.get('example_sentence') or '', + 'level': body.get('level') or 'B1', + 'part_of_speech': body.get('part_of_speech') or 'noun', + 'category': body.get('category') or 'general', + 'active': body.get('active', True), + } + rec = request.env['encoach.vocab.item'].sudo().create(vals) + return _json_response(_ser_vocab(rec)) + except Exception as e: + _logger.exception('create_vocab') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/vocabulary/', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_vocab(self, vid, **kw): + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if not rec.exists(): + return _error_response('Not found', 404) + prog = request.env['encoach.vocab.progress'].sudo().search([ + ('user_id', '=', request.env.user.id), + ('vocab_id', '=', rec.id), + ], limit=1) + return _json_response(_ser_vocab(rec, prog)) + + @http.route('/api/training/vocabulary/', type='http', + auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_vocab(self, vid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + for k in ('word', 'meaning', 'example_sentence', 'level', + 'part_of_speech', 'category'): + if k in body: + vals[k] = body[k] + if 'active' in body: + vals['active'] = bool(body['active']) + if vals: + rec.write(vals) + return _json_response(_ser_vocab(rec)) + except Exception as e: + _logger.exception('update_vocab') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/vocabulary/', type='http', + auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_vocab(self, vid, **kw): + try: + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_vocab') + return _error_response(str(e), 500) + + @http.route('/api/training/vocabulary//progress', + type='http', auth='public', + methods=['POST', 'PATCH'], csrf=False) + @jwt_required + def set_vocab_progress(self, vid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if not rec.exists(): + return _error_response('Not found', 404) + Progress = request.env['encoach.vocab.progress'].sudo() + uid = request.env.user.id + prog = Progress.search([ + ('user_id', '=', uid), + ('vocab_id', '=', rec.id), + ], limit=1) + vals = { + 'user_id': uid, + 'vocab_id': rec.id, + 'last_reviewed': odoo_fields.Datetime.now(), + } + if 'completed' in body: + vals['completed'] = bool(body['completed']) + if body.get('mastery') in ('learning', 'familiar', 'mastered'): + vals['mastery'] = body['mastery'] + if prog: + vals['review_count'] = (prog.review_count or 0) + 1 + prog.write(vals) + else: + vals['review_count'] = 1 + prog = Progress.create(vals) + return _json_response(_ser_vocab(rec, prog)) + except Exception as e: + _logger.exception('set_vocab_progress') + return _error_response(str(e), 500) + + +# ── Grammar ───────────────────────────────────────────────────────────── +class GrammarController(http.Controller): + + @http.route('/api/training/grammar', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_grammar(self, **kw): + try: + Rule = request.env['encoach.grammar.rule'].sudo() + domain = _domain_from_kw(kw, ['name', 'description', 'category']) + size, page = _pager(kw) + total = Rule.search_count(domain) + recs = Rule.search(domain, limit=size, offset=(page - 1) * size) + + Progress = request.env['encoach.grammar.progress'].sudo() + uid = request.env.user.id + prog_map = { + p.rule_id.id: p for p in Progress.search([ + ('user_id', '=', uid), + ('rule_id', 'in', recs.ids), + ]) + } if recs else {} + + items = [_ser_rule(r, prog_map.get(r.id)) for r in recs] + completed = sum(1 for i in items if i['completed']) + return _json_response({ + 'data': items, 'items': items, 'total': total, + 'summary': { + 'total': total, + 'completed': completed, + 'remaining': total - completed, + 'completion_rate': round((completed / total) * 100, 1) if total else 0.0, + }, + 'page': page, 'size': size, + }) + except Exception as e: + _logger.exception('list_grammar') + return _error_response(str(e), 500) + + @http.route('/api/training/grammar', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_rule(self, **kw): + try: + body = _get_json_body() + if not body.get('name') or not body.get('description'): + return _error_response('name and description are required', 400) + vals = { + 'name': body['name'].strip(), + 'description': body['description'].strip(), + 'example': body.get('example') or '', + 'level': body.get('level') or 'B1', + 'category': body.get('category') or 'general', + 'active': body.get('active', True), + } + rec = request.env['encoach.grammar.rule'].sudo().create(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + _logger.exception('create_rule') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/grammar/', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_rule(self, rid, **kw): + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + prog = request.env['encoach.grammar.progress'].sudo().search([ + ('user_id', '=', request.env.user.id), + ('rule_id', '=', rec.id), + ], limit=1) + return _json_response(_ser_rule(rec, prog)) + + @http.route('/api/training/grammar/', type='http', + auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_rule(self, rid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + for k in ('name', 'description', 'example', 'level', 'category'): + if k in body: + vals[k] = body[k] + if 'active' in body: + vals['active'] = bool(body['active']) + if vals: + rec.write(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + _logger.exception('update_rule') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/grammar/', type='http', + auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_rule(self, rid, **kw): + try: + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_rule') + return _error_response(str(e), 500) + + @http.route('/api/training/grammar//progress', + type='http', auth='public', + methods=['POST', 'PATCH'], csrf=False) + @jwt_required + def set_rule_progress(self, rid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + Progress = request.env['encoach.grammar.progress'].sudo() + uid = request.env.user.id + prog = Progress.search([ + ('user_id', '=', uid), + ('rule_id', '=', rec.id), + ], limit=1) + vals = { + 'user_id': uid, + 'rule_id': rec.id, + 'last_reviewed': odoo_fields.Datetime.now(), + } + if 'completed' in body: + vals['completed'] = bool(body['completed']) + if prog: + vals['review_count'] = (prog.review_count or 0) + 1 + prog.write(vals) + else: + vals['review_count'] = 1 + prog = Progress.create(vals) + return _json_response(_ser_rule(rec, prog)) + except Exception as e: + _logger.exception('set_rule_progress') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/controllers/users_roles.py b/backend/custom_addons/encoach_lms_api/controllers/users_roles.py new file mode 100644 index 00000000..4931679b --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/users_roles.py @@ -0,0 +1,542 @@ +import logging +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _detect_user_type(u): + """Detect effective user type from user_type field, groups, and linked records.""" + ut = getattr(u, 'user_type', '') or '' + if ut == 'admin': + return 'admin' + if ut == 'teacher': + return 'teacher' + if u.has_group('base.group_system') or u.has_group('base.group_erp_manager'): + return 'admin' + is_faculty = bool(u.env['op.faculty'].sudo().search([('user_id', '=', u.id)], limit=1)) + if is_faculty: + return 'teacher' + is_student = bool(u.env['op.student'].sudo().search([('user_id', '=', u.id)], limit=1)) + if is_student: + return 'student' + if u.has_group('base.group_portal'): + return 'student' + return ut or 'user' + + +def _ser_user(u): + role_ids = u.role_ids.ids if hasattr(u, 'role_ids') else [] + roles = [{'id': r.id, 'name': r.name or ''} for r in u.role_ids] if hasattr(u, 'role_ids') else [] + return { + 'id': u.id, + 'name': u.name or '', + 'email': u.email or u.login or '', + 'login': u.login or '', + 'user_type': _detect_user_type(u), + 'is_verified': True, + 'active': u.active, + 'phone': u.phone or '', + 'gender': getattr(u, 'gender', '') or '', + 'role_ids': role_ids, + 'roles': roles, + 'effective_permission_count': len(u.get_all_permissions()) if hasattr(u, 'get_all_permissions') else 0, + } + + +def _ser_role(r): + """Full role serialization for the frontend Role type.""" + entity = r.entity_id + return { + 'id': r.id, + 'name': r.name or '', + 'code': getattr(r, 'code', '') or '', + 'description': getattr(r, 'description', '') or '', + 'entity_id': entity.id if entity else None, + 'entity_name': entity.name if entity else '', + 'permission_ids': r.permission_ids.ids, + 'permissions': [ + { + 'id': p.id, + 'name': getattr(p, 'name', '') or p.code or '', + 'code': p.code or '', + 'description': getattr(p, 'description', '') or '', + 'category': getattr(p, 'topic', '') or '', + } + for p in r.permission_ids + ], + 'user_count': getattr(r, 'user_count', 0) or len(r.user_ids) if hasattr(r, 'user_ids') else 0, + } + + +def _ser_perm(p): + return { + 'id': p.id, + 'name': getattr(p, 'name', '') or p.code or '', + 'code': p.code or '', + 'description': getattr(p, 'description', '') or '', + 'category': getattr(p, 'topic', '') or '', + } + + +class UsersRolesController(http.Controller): + + # ── Users ──────────────────────────────────────────────────────── + + @http.route('/api/users/list', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_users(self, **kw): + try: + M = request.env['res.users'].sudo() + domain = [('share', '=', False)] + if kw.get('type'): + domain.append(('user_type', '=', kw['type'])) + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_user(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_users') + return _error_response(str(e), 500) + + @http.route('/api/users/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_user(self, uid_param, **kw): + try: + rec = request.env['res.users'].sudo().browse(uid_param) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _ser_user(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/users/update', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_user(self, **kw): + try: + body = _get_json_body() + uid_val = body.get('id') + if not uid_val: + return _error_response('Missing id', 400) + rec = request.env['res.users'].sudo().browse(int(uid_val)) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'email' in body: + vals['email'] = body['email'] + if 'phone' in body: + vals['phone'] = body['phone'] + if vals: + rec.write(vals) + return _json_response({'data': _ser_user(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/users/create', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_user(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'login': body.get('email', body.get('login', '')), + 'email': body.get('email', ''), + 'password': body.get('password', 'user123'), + } + rec = request.env['res.users'].sudo().create(vals) + return _json_response({'data': _ser_user(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/batch_users', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_batch_users(self, **kw): + try: + body = _get_json_body() + users = body.get('users', []) + created = [] + for u in users: + vals = { + 'name': u.get('name', ''), + 'login': u.get('email', u.get('login', '')), + 'email': u.get('email', ''), + 'password': u.get('password', 'user123'), + } + rec = request.env['res.users'].sudo().create(vals) + created.append(rec.id) + return _json_response({'success': True, 'created_ids': created}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Roles ──────────────────────────────────────────────────────── + + @http.route('/api/roles', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_roles(self, **kw): + try: + M = request.env['encoach.role'].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('entity_id'): + domain.append(('entity_id', '=', int(kw['entity_id']))) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_role(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_role(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_role(self, **kw): + try: + body = _get_json_body() + name = body.get('name', '').strip() + if not name: + return _error_response('Name is required', 400) + + entity_id = body.get('entity_id') + if not entity_id: + entity = request.env['encoach.entity'].sudo().search([], limit=1) + entity_id = entity.id if entity else False + if not entity_id: + return _error_response('No entity available for role', 400) + + vals = { + 'name': name, + 'entity_id': int(entity_id), + } + if body.get('code'): + vals['code'] = body['code'] + if body.get('description'): + vals['description'] = body['description'] + if body.get('permission_ids'): + vals['permission_ids'] = [(6, 0, [int(p) for p in body['permission_ids']])] + + rec = request.env['encoach.role'].sudo().create(vals) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_role(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'code' in body: + vals['code'] = body['code'] + if 'description' in body: + vals['description'] = body['description'] + if vals: + rec.write(vals) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_role(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles//permissions', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_role_permissions(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + perm_ids = body.get('permission_ids', []) + rec.write({'permission_ids': [(6, 0, [int(p) for p in perm_ids])]}) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Permissions ────────────────────────────────────────────────── + + @http.route('/api/permissions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_permissions(self, **kw): + try: + M = request.env['encoach.permission'].sudo() + domain = [] + if kw.get('category'): + domain.append(('topic', '=', kw['category'])) + if kw.get('search'): + domain.append(('code', 'ilike', kw['search'])) + recs = M.search(domain, limit=500, order='topic, code') + items = [_ser_perm(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': len(items)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/permissions', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_permission(self, **kw): + try: + body = _get_json_body() + code = body.get('code', body.get('name', '')).strip() + if not code: + return _error_response('Code is required', 400) + vals = {'code': code} + if body.get('name'): + vals['name'] = body['name'] + if body.get('description'): + vals['description'] = body['description'] + if body.get('topic') or body.get('category'): + vals['topic'] = body.get('topic') or body.get('category', '') + rec = request.env['encoach.permission'].sudo().create(vals) + return _json_response({'data': _ser_perm(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/permissions/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_permission(self, pid, **kw): + try: + rec = request.env['encoach.permission'].sudo().browse(pid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Authority Matrix ───────────────────────────────────────────── + + @http.route('/api/authority-matrix', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_authority_matrix(self, **kw): + try: + roles = request.env['encoach.role'].sudo().search([]) + perms = request.env['encoach.permission'].sudo().search([], order='topic, code') + + categories = {} + for p in perms: + cat = p.topic or 'General' + categories.setdefault(cat, []).append(_ser_perm(p)) + + role_list = [] + for r in roles: + role_list.append({ + 'id': r.id, + 'name': r.name or '', + 'description': getattr(r, 'description', '') or '', + 'granted_permission_ids': r.permission_ids.ids, + }) + + return _json_response({ + 'roles': role_list, + 'permissions': [_ser_perm(p) for p in perms], + 'categories': categories, + 'matrix': {str(r.id): r.permission_ids.ids for r in roles}, + 'total_roles': len(roles), + 'total_permissions': len(perms), + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/authority-matrix/toggle', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def toggle_matrix(self, **kw): + try: + body = _get_json_body() + role = request.env['encoach.role'].sudo().browse(int(body.get('role_id', 0))) + perm_id = int(body.get('permission_id', 0)) + if not role.exists(): + return _error_response('Role not found', 404) + current = role.permission_ids.ids + if perm_id in current: + role.write({'permission_ids': [(3, perm_id)]}) + granted = False + else: + role.write({'permission_ids': [(4, perm_id)]}) + granted = True + return _json_response({'role_id': role.id, 'permission_id': perm_id, 'granted': granted}) + except Exception as e: + return _error_response(str(e), 500) + + # ── User-Role Assignment ───────────────────────────────────────── + + @http.route('/api/users/with-roles', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_users_with_roles(self, **kw): + try: + M = request.env['res.users'].sudo() + domain = [('share', '=', False)] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('role_id'): + domain.append(('role_ids', 'in', [int(kw['role_id'])])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [] + for u in recs: + role_ids = u.role_ids.ids if hasattr(u, 'role_ids') else [] + all_perms = u.get_all_permissions() if hasattr(u, 'get_all_permissions') else u.role_ids.mapped('permission_ids') + items.append({ + 'id': u.id, + 'name': u.name or '', + 'login': u.login or '', + 'email': u.email or u.login or '', + 'active': u.active, + 'role_ids': role_ids, + 'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids], + 'effective_permission_count': len(all_perms), + }) + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_users_with_roles') + return _error_response(str(e), 500) + + @http.route('/api/users//roles', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_user_roles(self, uid_param, **kw): + try: + user = request.env['res.users'].sudo().browse(uid_param) + if not user.exists(): + return _error_response('User not found', 404) + + all_roles = request.env['encoach.role'].sudo().search([]) + assigned_ids = user.role_ids.ids if hasattr(user, 'role_ids') else [] + + all_perms = user.get_all_permissions() if hasattr(user, 'get_all_permissions') else request.env['encoach.permission'] + + roles_detail = [] + for r in all_roles: + roles_detail.append({ + 'id': r.id, + 'name': r.name or '', + 'description': getattr(r, 'description', '') or '', + 'assigned': r.id in assigned_ids, + 'permission_count': len(r.permission_ids), + }) + + return _json_response({ + 'user': { + 'id': user.id, + 'name': user.name or '', + 'login': user.login or '', + 'email': user.email or '', + }, + 'roles': roles_detail, + 'effective_permissions': [_ser_perm(p) for p in all_perms], + }) + except Exception as e: + _logger.exception('get_user_roles') + return _error_response(str(e), 500) + + @http.route('/api/users//roles', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_user_roles(self, uid_param, **kw): + try: + user = request.env['res.users'].sudo().browse(uid_param) + if not user.exists(): + return _error_response('User not found', 404) + body = _get_json_body() + role_ids = body.get('role_ids', []) + user.write({'role_ids': [(6, 0, [int(r) for r in role_ids])]}) + return _json_response({ + 'success': True, + 'user_id': user.id, + 'role_ids': user.role_ids.ids, + }) + except Exception as e: + _logger.exception('update_user_roles') + return _error_response(str(e), 500) + + @http.route('/api/users//roles/toggle', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def toggle_user_role(self, uid_param, **kw): + try: + user = request.env['res.users'].sudo().browse(uid_param) + if not user.exists(): + return _error_response('User not found', 404) + body = _get_json_body() + role_id = int(body.get('role_id', 0)) + if not role_id: + return _error_response('Missing role_id', 400) + + current = user.role_ids.ids if hasattr(user, 'role_ids') else [] + if role_id in current: + user.write({'role_ids': [(3, role_id)]}) + assigned = False + else: + user.write({'role_ids': [(4, role_id)]}) + assigned = True + return _json_response({ + 'user_id': user.id, + 'role_id': role_id, + 'assigned': assigned, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/user-authority-matrix', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_user_authority_matrix(self, **kw): + try: + users = request.env['res.users'].sudo().search([ + ('share', '=', False), + ], order='name', limit=200) + perms = request.env['encoach.permission'].sudo().search([], order='topic, code') + + categories = {} + for p in perms: + cat = p.topic or 'General' + categories.setdefault(cat, []).append(_ser_perm(p)) + + user_list = [] + for u in users: + all_perms = u.get_all_permissions() if hasattr(u, 'get_all_permissions') else u.role_ids.mapped('permission_ids') + user_list.append({ + 'id': u.id, + 'name': u.name or '', + 'login': u.login or '', + 'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids], + 'granted_permission_ids': all_perms.ids, + }) + + return _json_response({ + 'users': user_list, + 'permissions': [_ser_perm(p) for p in perms], + 'categories': categories, + 'total_users': len(user_list), + 'total_permissions': len(perms), + }) + except Exception as e: + _logger.exception('get_user_authority_matrix') + return _error_response(str(e), 500) diff --git a/backend/custom_addons/encoach_lms_api/models/__init__.py b/backend/custom_addons/encoach_lms_api/models/__init__.py new file mode 100644 index 00000000..b9ed3cb3 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/__init__.py @@ -0,0 +1,12 @@ +from . import lesson +from . import student_leave +from . import gradebook +from . import communication +from . import courseware +from . import notification +from . import faq +from . import course_ext +from . import asset +from . import ticket +from . import training +from . import paymob_order diff --git a/backend/custom_addons/encoach_lms_api/models/asset.py b/backend/custom_addons/encoach_lms_api/models/asset.py new file mode 100644 index 00000000..97c6e64a --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/asset.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class EncoachAsset(models.Model): + _name = 'encoach.asset' + _description = 'Institutional Asset (lightweight)' + _order = 'id desc' + + name = fields.Char('Name', required=True) + code = fields.Char('Code') + facility_id = fields.Many2one('op.facility', string='Facility') + description = fields.Text('Description') + active = fields.Boolean('Active', default=True) diff --git a/backend/custom_addons/encoach_lms_api/models/communication.py b/backend/custom_addons/encoach_lms_api/models/communication.py new file mode 100644 index 00000000..0b2c977c --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/communication.py @@ -0,0 +1,69 @@ +from odoo import models, fields + + +class EncoachDiscussionBoard(models.Model): + _name = 'encoach.discussion.board' + _description = 'Discussion Board' + + name = fields.Char(required=True) + course_id = fields.Many2one('op.course', ondelete='cascade') + batch_id = fields.Many2one('op.batch', ondelete='set null') + chapter_id = fields.Many2one('encoach.course.chapter', ondelete='set null') + is_enabled = fields.Boolean(default=True) + allow_student_posts = fields.Boolean(default=True) + post_ids = fields.One2many('encoach.discussion.post', 'board_id') + post_count = fields.Integer(compute='_compute_post_count', store=True) + + def _compute_post_count(self): + for rec in self: + rec.post_count = len(rec.post_ids) + + +class EncoachDiscussionPost(models.Model): + _name = 'encoach.discussion.post' + _description = 'Discussion Post' + _order = 'create_date desc' + + board_id = fields.Many2one('encoach.discussion.board', required=True, ondelete='cascade') + parent_id = fields.Many2one('encoach.discussion.post', string='Parent', ondelete='cascade') + child_ids = fields.One2many('encoach.discussion.post', 'parent_id', string='Replies') + author_id = fields.Many2one('res.users', required=True, ondelete='cascade') + title = fields.Char() + content = fields.Text(required=True) + is_pinned = fields.Boolean(default=False) + is_resolved = fields.Boolean(default=False) + + +class EncoachAnnouncement(models.Model): + _name = 'encoach.announcement' + _description = 'Announcement' + _order = 'create_date desc' + + title = fields.Char(required=True) + content = fields.Text(required=True) + author_id = fields.Many2one('res.users', required=True, ondelete='cascade') + course_id = fields.Many2one('op.course', ondelete='set null') + batch_id = fields.Many2one('op.batch', ondelete='set null') + priority = fields.Selection([ + ('normal', 'Normal'), + ('important', 'Important'), + ('urgent', 'Urgent'), + ], default='normal') + is_published = fields.Boolean(default=False) + published_at = fields.Datetime() + expires_at = fields.Datetime() + send_email = fields.Boolean(default=False) + + +class EncoachMessage(models.Model): + _name = 'encoach.message' + _description = 'Direct Message' + _order = 'create_date desc' + + sender_id = fields.Many2one('res.users', required=True, ondelete='cascade') + recipient_id = fields.Many2one('res.users', required=True, ondelete='cascade') + subject = fields.Char(required=True) + content = fields.Text(required=True) + is_read = fields.Boolean(default=False) + read_at = fields.Datetime() + send_email_copy = fields.Boolean(default=False) diff --git a/backend/custom_addons/encoach_lms_api/models/course_ext.py b/backend/custom_addons/encoach_lms_api/models/course_ext.py new file mode 100644 index 00000000..4a9be14a --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/course_ext.py @@ -0,0 +1,56 @@ +from odoo import models, fields, api + + +class OpCourseExt(models.Model): + _inherit = 'op.course' + + description = fields.Text('Description') + max_capacity = fields.Integer('Max Capacity', default=30) + + encoach_subject_id = fields.Many2one( + 'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True, + ) + encoach_topic_ids = fields.Many2many( + 'encoach.topic', 'op_course_encoach_topic_rel', + 'course_id', 'topic_id', string='Topics', + ) + learning_objective_ids = fields.Many2many( + 'encoach.learning.objective', 'op_course_learning_obj_rel', + 'course_id', 'objective_id', string='Learning Objectives', + ) + encoach_tag_ids = fields.Many2many( + 'encoach.resource.tag', 'op_course_resource_tag_rel', + 'course_id', 'tag_id', string='Tags', + ) + difficulty_level = fields.Selection([ + ('beginner', 'Beginner'), + ('intermediate', 'Intermediate'), + ('advanced', 'Advanced'), + ]) + cefr_level = fields.Selection([ + ('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'), + ('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'), + ]) + + chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters') + chapter_count = fields.Integer(compute='_compute_chapter_count', store=True) + objective_count = fields.Integer(compute='_compute_objective_count') + resource_count = fields.Integer(compute='_compute_resource_count') + + @api.depends('chapter_ids') + def _compute_chapter_count(self): + for rec in self: + rec.chapter_count = len(rec.chapter_ids) + + def _compute_objective_count(self): + for rec in self: + rec.objective_count = len(rec.learning_objective_ids) + + def _compute_resource_count(self): + Mat = self.env['encoach.chapter.material'].sudo() + for rec in self: + mats = Mat.search([ + ('chapter_id.course_id', '=', rec.id), + ('resource_id', '!=', False), + ]) + rec.resource_count = len(mats.mapped('resource_id')) diff --git a/backend/custom_addons/encoach_lms_api/models/courseware.py b/backend/custom_addons/encoach_lms_api/models/courseware.py new file mode 100644 index 00000000..ae9e4a60 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/courseware.py @@ -0,0 +1,235 @@ +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class EncoachCourseChapter(models.Model): + _name = 'encoach.course.chapter' + _description = 'Course Chapter' + _order = 'sequence, id' + + name = fields.Char(required=True) + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + sequence = fields.Integer(default=10) + description = fields.Text() + start_date = fields.Date() + end_date = fields.Date() + unlock_mode = fields.Selection([ + ('auto_date', 'Automatic by Date'), + ('manual', 'Manual'), + ('prerequisite', 'Prerequisite'), + ], default='manual') + is_unlocked = fields.Boolean(default=True) + topic_id = fields.Many2one('encoach.topic', ondelete='set null') + domain_id = fields.Many2one( + 'encoach.domain', compute='_compute_domain_id', store=True, + ondelete='set null', string='Domain', + ) + learning_objective_ids = fields.Many2many( + 'encoach.learning.objective', 'encoach_chapter_obj_rel', + 'chapter_id', 'objective_id', string='Learning Objectives', + ) + material_ids = fields.One2many('encoach.chapter.material', 'chapter_id') + material_count = fields.Integer(compute='_compute_material_count', store=True) + + @api.depends('topic_id', 'topic_id.domain_id') + def _compute_domain_id(self): + for rec in self: + rec.domain_id = rec.topic_id.domain_id.id if rec.topic_id and rec.topic_id.domain_id else False + + @api.depends('material_ids') + def _compute_material_count(self): + for rec in self: + rec.material_count = len(rec.material_ids) + + +class EncoachChapterMaterial(models.Model): + _name = 'encoach.chapter.material' + _description = 'Chapter Material' + _order = 'sequence, id' + + name = fields.Char(required=True) + chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade') + type = fields.Selection([ + ('pdf', 'PDF'), + ('document', 'Document'), + ('video', 'Video'), + ('audio', 'Audio'), + ('image', 'Image'), + ('link', 'Link'), + ('article', 'Article'), + ], default='pdf') + file_data = fields.Binary(attachment=True) + file_name = fields.Char() + url = fields.Char() + description = fields.Text() + sequence = fields.Integer(default=10) + allow_download = fields.Boolean(default=True) + is_book = fields.Boolean(default=False) + book_chapters = fields.Text() + resource_id = fields.Many2one('encoach.resource', ondelete='set null', + help="Auto-created library resource mirror") + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._vector_index() + records._sync_library_resource() + return records + + def write(self, vals): + res = super().write(vals) + if self.env.context.get('no_sync'): + return res + if any(f in vals for f in ('name', 'description', 'type')): + self._vector_index() + sync_fields = ('name', 'type', 'url', 'file_data') + if any(f in vals for f in sync_fields): + self._sync_library_resource() + return res + + def unlink(self): + self._vector_delete() + resources = self.mapped('resource_id').filtered('exists') + res = super().unlink() + resources.unlink() + return res + + def _sync_library_resource(self): + """Create or update a matching encoach.resource for each material, propagating taxonomy.""" + Res = self.env.get('encoach.resource') + if Res is None: + return + Res = self.env['encoach.resource'].sudo() + type_map = { + 'pdf': 'pdf', 'document': 'document', 'video': 'video', + 'audio': 'pdf', 'image': 'pdf', 'link': 'link', 'article': 'document', + } + for rec in self: + vals = { + 'name': rec.name, + 'type': type_map.get(rec.type, 'document'), + 'url': rec.url or '', + 'review_status': 'approved', + } + if rec.file_data: + vals['file'] = rec.file_data + chapter = rec.chapter_id + if chapter: + if chapter.topic_id: + vals['topic_ids'] = [(4, chapter.topic_id.id)] + if chapter.domain_id: + vals['domain_id'] = chapter.domain_id.id + course = chapter.course_id + if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: + vals['subject_id'] = course.encoach_subject_id.id + if chapter.learning_objective_ids: + vals['learning_objective_ids'] = [(6, 0, chapter.learning_objective_ids.ids)] + if rec.resource_id and rec.resource_id.exists(): + rec.resource_id.write(vals) + else: + new_res = Res.create(vals) + rec.with_context(no_sync=True).write({'resource_id': new_res.id}) + + def _vector_index(self): + """Index material into the vector store for RAG retrieval with full taxonomy context.""" + try: + svc_mod = self.env.get('encoach.embedding') + if svc_mod is None: + return + from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService + svc = EmbeddingService(self.env) + for rec in self: + parts = [rec.name or ''] + if rec.description: + parts.append(str(rec.description)[:2000]) + chapter = rec.chapter_id + if chapter: + if chapter.course_id: + parts.append(f"Course: {chapter.course_id.name}") + parts.append(f"Chapter: {chapter.name}") + if chapter.topic_id: + parts.append(f"Topic: {chapter.topic_id.name}") + if chapter.domain_id: + parts.append(f"Domain: {chapter.domain_id.name}") + course = chapter.course_id + if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: + parts.append(f"Subject: {course.encoach_subject_id.name}") + for obj in chapter.learning_objective_ids: + parts.append(f"Objective: {obj.name}") + text = ' '.join(p for p in parts if p).strip() + if text: + metadata = {'type': rec.type or 'pdf'} + if chapter: + metadata['chapter_id'] = chapter.id + metadata['chapter_name'] = chapter.name or '' + if chapter.course_id: + metadata['course_id'] = chapter.course_id.id + metadata['course_name'] = chapter.course_id.name or '' + if chapter.topic_id: + metadata['topic_id'] = chapter.topic_id.id + metadata['topic_name'] = chapter.topic_id.name or '' + if chapter.domain_id: + metadata['domain_id'] = chapter.domain_id.id + metadata['domain_name'] = chapter.domain_id.name or '' + course = chapter.course_id + if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: + metadata['subject_id'] = course.encoach_subject_id.id + metadata['subject_name'] = course.encoach_subject_id.name or '' + if chapter.learning_objective_ids: + metadata['objective_ids'] = chapter.learning_objective_ids.ids + metadata['objective_names'] = chapter.learning_objective_ids.mapped('name') + svc.upsert('material', rec.id, text, metadata) + self.env.cr.commit() + except Exception: + _logger.warning("Failed to vector-index material(s)", exc_info=True) + + def _vector_delete(self): + """Remove material embeddings from the vector store.""" + try: + svc_mod = self.env.get('encoach.embedding') + if svc_mod is None: + return + from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService + svc = EmbeddingService(self.env) + for rec in self: + svc.delete('material', rec.id) + except Exception: + _logger.warning("Failed to delete material vector(s)", exc_info=True) + + +class EncoachChapterProgress(models.Model): + _name = 'encoach.chapter.progress' + _description = 'Chapter Progress' + _rec_name = 'chapter_id' + + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade') + status = fields.Selection([ + ('not_started', 'Not Started'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ], default='not_started') + started_at = fields.Datetime() + completed_at = fields.Datetime() + materials_completed = fields.Integer(default=0) + materials_total = fields.Integer(default=0) + + +class EncoachCourseCompletion(models.Model): + _name = 'encoach.course.completion' + _description = 'Course Completion' + _rec_name = 'course_id' + + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + status = fields.Selection([ + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ], default='in_progress') + chapters_total = fields.Integer(default=0) + chapters_completed = fields.Integer(default=0) + progress_percent = fields.Integer(default=0) + completed_at = fields.Datetime() + post_test_available = fields.Boolean(default=False) diff --git a/backend/custom_addons/encoach_lms_api/models/faq.py b/backend/custom_addons/encoach_lms_api/models/faq.py new file mode 100644 index 00000000..8724dc28 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/faq.py @@ -0,0 +1,43 @@ +from odoo import api, models, fields + + +FAQ_AUDIENCE = [ + ('all', 'All'), + ('both', 'Both (Student + Entity)'), + ('student', 'Students'), + ('teacher', 'Teachers'), + ('admin', 'Admins'), + ('entity', 'Entity Users'), +] + + +class EncoachFaqCategory(models.Model): + _name = 'encoach.faq.category' + _description = 'FAQ Category' + _order = 'sequence, id' + + name = fields.Char(required=True) + sequence = fields.Integer(default=10) + audience = fields.Selection(FAQ_AUDIENCE, default='all') + is_published = fields.Boolean(default=True) + item_ids = fields.One2many('encoach.faq.item', 'category_id') + item_count = fields.Integer(compute='_compute_item_count', store=True) + + @api.depends('item_ids') + def _compute_item_count(self): + for rec in self: + rec.item_count = len(rec.item_ids) + + +class EncoachFaqItem(models.Model): + _name = 'encoach.faq.item' + _description = 'FAQ Item' + _order = 'sequence, id' + + category_id = fields.Many2one('encoach.faq.category', required=True, ondelete='cascade') + question = fields.Char(required=True) + answer = fields.Text(required=True) + sequence = fields.Integer(default=10) + audience = fields.Selection(FAQ_AUDIENCE, default='all') + is_published = fields.Boolean(default=True) + video_url = fields.Char(help='Optional walkthrough video URL.') diff --git a/backend/custom_addons/encoach_lms_api/models/gradebook.py b/backend/custom_addons/encoach_lms_api/models/gradebook.py new file mode 100644 index 00000000..f4708573 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/gradebook.py @@ -0,0 +1,26 @@ +from odoo import models, fields + + +class EncoachGradebook(models.Model): + _name = 'encoach.gradebook' + _description = 'Gradebook' + + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + academic_year_id = fields.Many2one('op.academic.year', ondelete='set null') + line_ids = fields.One2many('encoach.gradebook.line', 'gradebook_id') + + +class EncoachGradebookLine(models.Model): + _name = 'encoach.gradebook.line' + _description = 'Gradebook Line' + + gradebook_id = fields.Many2one('encoach.gradebook', required=True, ondelete='cascade') + assignment_name = fields.Char() + marks = fields.Float() + percentage = fields.Float() + state = fields.Selection([ + ('draft', 'Draft'), + ('submitted', 'Submitted'), + ('graded', 'Graded'), + ], default='draft') diff --git a/backend/custom_addons/encoach_lms_api/models/lesson.py b/backend/custom_addons/encoach_lms_api/models/lesson.py new file mode 100644 index 00000000..30bb5c27 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/lesson.py @@ -0,0 +1,24 @@ +from odoo import models, fields + + +class EncoachLesson(models.Model): + _name = 'encoach.lesson' + _description = 'Lesson' + _order = 'start_datetime desc, id desc' + + name = fields.Char(string='Name') + lesson_topic = fields.Char(string='Topic') + course_id = fields.Many2one('op.course', string='Course', ondelete='cascade') + batch_id = fields.Many2one('op.batch', string='Batch', ondelete='set null') + subject_id = fields.Many2one('op.subject', string='Subject', ondelete='set null') + session_id = fields.Many2one('op.session', string='Session', ondelete='set null') + faculty_id = fields.Many2one('op.faculty', string='Faculty', ondelete='set null') + start_datetime = fields.Datetime(string='Start') + end_datetime = fields.Datetime(string='End') + content = fields.Text(string='Content') + status = fields.Selection([ + ('draft', 'Draft'), + ('scheduled', 'Scheduled'), + ('done', 'Done'), + ('cancelled', 'Cancelled'), + ], default='draft', string='Status') diff --git a/backend/custom_addons/encoach_lms_api/models/notification.py b/backend/custom_addons/encoach_lms_api/models/notification.py new file mode 100644 index 00000000..2eae902c --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/notification.py @@ -0,0 +1,71 @@ +from odoo import models, fields + + +class EncoachNotification(models.Model): + _name = 'encoach.notification' + _description = 'Notification' + _order = 'create_date desc' + + user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + title = fields.Char(required=True) + message = fields.Text() + type = fields.Selection([ + ('info', 'Info'), + ('warning', 'Warning'), + ('success', 'Success'), + ('error', 'Error'), + ], default='info') + is_read = fields.Boolean(default=False) + read_at = fields.Datetime() + reference_model = fields.Char() + reference_id = fields.Integer() + + +class EncoachNotificationRule(models.Model): + _name = 'encoach.notification.rule' + _description = 'Notification Rule' + + name = fields.Char(required=True) + event_type = fields.Char(required=True, help='e.g. deadline, chapter_unlock, ' + 'result_release, assignment, exam') + template = fields.Text() + is_active = fields.Boolean(default=True) + recipients = fields.Selection([ + ('all', 'All Users'), + ('students', 'Students Only'), + ('teachers', 'Teachers Only'), + ('admins', 'Admins Only'), + ], default='all') + channels = fields.Char(help='Legacy: comma-separated email,push,in_app. ' + 'New code should use `channel` instead.') + + # New fields aligned with the admin Configuration UI (NotificationRules page) + days_before = fields.Integer(default=1, + help='Lead time in days before the event fires.') + frequency = fields.Selection([ + ('once', 'Once'), + ('daily', 'Daily'), + ('custom', 'Custom schedule'), + ], default='once') + channel = fields.Selection([ + ('in_app', 'In-App'), + ('email', 'Email'), + ('both', 'Both'), + ], default='in_app') + entity_id = fields.Many2one('encoach.entity', ondelete='set null') + + +class EncoachNotificationPreference(models.Model): + _name = 'encoach.notification.preference' + _description = 'Notification Preference' + _rec_name = 'user_id' + + user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + email_enabled = fields.Boolean(default=True) + push_enabled = fields.Boolean(default=True) + in_app_enabled = fields.Boolean(default=True) + digest_frequency = fields.Selection([ + ('realtime', 'Real-time'), + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ], default='realtime') diff --git a/backend/custom_addons/encoach_lms_api/models/paymob_order.py b/backend/custom_addons/encoach_lms_api/models/paymob_order.py new file mode 100644 index 00000000..f4c036ae --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/paymob_order.py @@ -0,0 +1,92 @@ +"""Paymob payment order tracking. + +We keep an auditable trail of every checkout we initiate with Paymob so we +can (a) show users the status of their payments, (b) reconcile against the +Paymob dashboard, and (c) prove/dispute webhook events with the signed +HMAC we stored. + +Lifecycle: + +* ``draft`` — row created but no Paymob API call made yet +* ``initiated`` — authentication + order registration succeeded, we have a + ``payment_key`` we can redirect the user to +* ``paid`` — the ``TRANSACTION`` webhook fired with ``success=true`` + and a verified HMAC +* ``failed`` — webhook fired with ``success=false`` +* ``cancelled`` — user abandoned / timed out (set manually or by cron) +""" + +from odoo import fields, models + + +class EncoachPaymobOrder(models.Model): + _name = "encoach.paymob.order" + _description = "Paymob payment order" + _order = "create_date desc" + _rec_name = "reference" + + reference = fields.Char( + required=True, index=True, + help="Our merchant order reference; included in the Paymob request.", + ) + user_id = fields.Many2one( + "res.users", required=True, ondelete="restrict", index=True, + ) + partner_id = fields.Many2one("res.partner", ondelete="set null") + + # Commerce fields + amount_cents = fields.Integer( + required=True, + help="Amount in smallest currency unit (piastres for EGP).", + ) + currency = fields.Char(required=True, default="EGP") + description = fields.Char() + product_ref = fields.Char( + help="Free-text link back to the thing being bought " + "(e.g. 'subscription:pro-monthly').", + ) + + # Paymob identifiers + paymob_order_id = fields.Char(index=True) + payment_key = fields.Text() + payment_key_expires_at = fields.Datetime() + integration_id = fields.Char( + help="Which Paymob integration was used (card / wallet / kiosk...)", + ) + + # State + state = fields.Selection( + [ + ("draft", "Draft"), + ("initiated", "Initiated"), + ("paid", "Paid"), + ("failed", "Failed"), + ("cancelled", "Cancelled"), + ], + default="draft", required=True, index=True, + ) + last_event_at = fields.Datetime() + last_event_payload = fields.Text( + help="JSON of the latest webhook payload we received for this order.", + ) + last_event_hmac = fields.Char( + help="HMAC we verified against the payload; stored for audit.", + ) + + def mark_paid(self, payload, hmac): + self.ensure_one() + self.write({ + "state": "paid", + "last_event_at": fields.Datetime.now(), + "last_event_payload": payload, + "last_event_hmac": hmac, + }) + + def mark_failed(self, payload, hmac): + self.ensure_one() + self.write({ + "state": "failed", + "last_event_at": fields.Datetime.now(), + "last_event_payload": payload, + "last_event_hmac": hmac, + }) diff --git a/backend/custom_addons/encoach_lms_api/models/student_leave.py b/backend/custom_addons/encoach_lms_api/models/student_leave.py new file mode 100644 index 00000000..748f95d7 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/student_leave.py @@ -0,0 +1,46 @@ +from odoo import models, fields, api + + +class EncoachStudentLeaveType(models.Model): + _name = 'encoach.student.leave.type' + _description = 'Student Leave Type' + + name = fields.Char(required=True) + + +class EncoachStudentLeave(models.Model): + _name = 'encoach.student.leave' + _description = 'Student Leave Request' + _order = 'create_date desc' + + request_number = fields.Char(string='Request #', readonly=True, copy=False) + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + leave_type_id = fields.Many2one('encoach.student.leave.type', required=True, ondelete='restrict') + start_date = fields.Date(required=True) + end_date = fields.Date(required=True) + duration = fields.Integer(compute='_compute_duration', store=True) + description = fields.Text() + state = fields.Selection([ + ('draft', 'Draft'), + ('confirm', 'Confirmed'), + ('approve', 'Approved'), + ('refuse', 'Refused'), + ('cancel', 'Cancelled'), + ], default='draft') + approve_date = fields.Date() + approved_by = fields.Many2one('res.users', ondelete='set null') + + @api.depends('start_date', 'end_date') + def _compute_duration(self): + for rec in self: + if rec.start_date and rec.end_date: + rec.duration = (rec.end_date - rec.start_date).days + 1 + else: + rec.duration = 0 + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('request_number'): + vals['request_number'] = self.env['ir.sequence'].next_by_code('encoach.student.leave') or 'SL-NEW' + return super().create(vals_list) diff --git a/backend/custom_addons/encoach_lms_api/models/ticket.py b/backend/custom_addons/encoach_lms_api/models/ticket.py new file mode 100644 index 00000000..39b1767a --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/ticket.py @@ -0,0 +1,167 @@ +import logging + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class EncoachTicket(models.Model): + _name = 'encoach.ticket' + _description = 'Support Ticket' + _inherit = ['mail.thread'] + _order = 'id desc' + + subject = fields.Char('Subject', required=True, tracking=True) + description = fields.Text('Description') + type = fields.Selection([ + ('bug', 'Bug'), + ('feature', 'Feature'), + ('help', 'Help'), + ('support', 'Support'), + ('feedback', 'Feedback'), + ], string='Type', default='support', required=True, tracking=True) + status = fields.Selection([ + ('open', 'Open'), + ('in_progress', 'In Progress'), + ('resolved', 'Resolved'), + ('closed', 'Closed'), + ], string='Status', default='open', required=True, tracking=True) + source = fields.Selection([ + ('platform', 'Platform'), + ('webpage', 'Webpage'), + ('email', 'Email'), + ], string='Source', default='platform') + + page_context = fields.Char('Page Context') + reporter_id = fields.Many2one('res.users', string='Reporter', required=True, + default=lambda self: self.env.user.id) + assignee_id = fields.Many2one('res.users', string='Assignee') + entity_id = fields.Many2one('encoach.entity', string='Entity') + + resolved_at = fields.Datetime('Resolved At') + + STATUS_LABELS = { + 'open': 'Open', + 'in_progress': 'In Progress', + 'resolved': 'Resolved', + 'closed': 'Closed', + } + + def _notify_ticket_event(self, event, *, old_status=None, new_status=None, + old_assignee=None, new_assignee=None): + """Post a chatter message and push a bus event for status/assignee changes. + + Uses :class:`bus.bus` when available so both the reporter and the new + assignee see near real-time updates in the frontend, and always falls + back to ``mail.thread`` chatter so the change is auditable even when + the bus is unavailable (e.g. during cron jobs or tests). + """ + self.ensure_one() + try: + if event == 'status': + body = _("Status: %s → %s") % ( + self.STATUS_LABELS.get(old_status, old_status or '—'), + self.STATUS_LABELS.get(new_status, new_status or '—'), + ) + elif event == 'assignee': + old_name = old_assignee.display_name if old_assignee else _('Unassigned') + new_name = new_assignee.display_name if new_assignee else _('Unassigned') + body = _("Assignee: %s → %s") % (old_name, new_name) + else: + return + self.message_post(body=body, subtype_xmlid='mail.mt_note') + except Exception: + _logger.exception("ticket %s: chatter notify failed", self.id) + + try: + bus = self.env['bus.bus'].sudo() + recipients = set() + if self.reporter_id: + recipients.add(('res.partner', self.reporter_id.partner_id.id)) + for user in (old_assignee, new_assignee, self.assignee_id): + if user and user.partner_id: + recipients.add(('res.partner', user.partner_id.id)) + payload = { + 'type': 'encoach.ticket', + 'event': event, + 'ticket_id': self.id, + 'subject': self.subject, + 'status': self.status, + 'assignee_id': self.assignee_id.id or False, + 'old_status': old_status, + 'new_status': new_status, + } + for channel in recipients: + bus._sendone(channel, 'encoach.ticket/notify', payload) + except Exception: + _logger.debug("ticket %s: bus push skipped", self.id, exc_info=True) + + def write(self, vals): + tracked = {} + if vals.get('status') or vals.get('assignee_id') is not None: + for rec in self: + tracked[rec.id] = { + 'status': rec.status, + 'assignee': rec.assignee_id, + } + res = super().write(vals) + if not tracked: + return res + + now = fields.Datetime.now() + for rec in self: + before = tracked.get(rec.id) or {} + old_status = before.get('status') + old_assignee = before.get('assignee') + if 'status' in vals and old_status != rec.status: + if rec.status in ('resolved', 'closed') and not rec.resolved_at: + rec.resolved_at = now + rec._notify_ticket_event( + 'status', old_status=old_status, new_status=rec.status, + ) + if 'assignee_id' in vals and (old_assignee or rec.assignee_id) and \ + (old_assignee.id if old_assignee else False) != (rec.assignee_id.id or False): + rec._notify_ticket_event( + 'assignee', old_assignee=old_assignee, new_assignee=rec.assignee_id, + ) + return res + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + try: + rec.message_post( + body=_("Ticket opened by %s") % rec.reporter_id.display_name, + subtype_xmlid='mail.mt_note', + ) + except Exception: + _logger.debug("ticket %s: initial chatter failed", rec.id, exc_info=True) + return records + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + 'encoach_ticket_entity_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_ticket_entity_status_idx " + "ON encoach_ticket (entity_id, status) WHERE entity_id IS NOT NULL", + ), + ( + 'encoach_ticket_reporter_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_ticket_reporter_status_idx " + "ON encoach_ticket (reporter_id, status)", + ), + ( + 'encoach_ticket_assignee_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_ticket_assignee_status_idx " + "ON encoach_ticket (assignee_id, status) WHERE assignee_id IS NOT NULL", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res diff --git a/backend/custom_addons/encoach_lms_api/models/training.py b/backend/custom_addons/encoach_lms_api/models/training.py new file mode 100644 index 00000000..c2574e88 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/training.py @@ -0,0 +1,157 @@ +"""Training library: vocabulary items, grammar rules and per-user progress. + +Backs the `/admin/training/vocabulary` and `/admin/training/grammar` pages +(previously hard-coded mocks). +""" +from odoo import api, fields, models + + +CEFR_LEVELS = [ + ('A1', 'A1'), + ('A2', 'A2'), + ('B1', 'B1'), + ('B2', 'B2'), + ('C1', 'C1'), + ('C2', 'C2'), +] + + +class EncoachVocabItem(models.Model): + _name = 'encoach.vocab.item' + _description = 'Training Vocabulary Item' + _order = 'level,word' + + word = fields.Char('Word', required=True, index=True) + meaning = fields.Text('Meaning', required=True) + example_sentence = fields.Text('Example Sentence') + level = fields.Selection(CEFR_LEVELS, string='CEFR Level', default='B1', required=True) + part_of_speech = fields.Selection([ + ('noun', 'Noun'), + ('verb', 'Verb'), + ('adjective', 'Adjective'), + ('adverb', 'Adverb'), + ('phrase', 'Phrase'), + ('other', 'Other'), + ], string='Part of Speech', default='noun') + category = fields.Char('Category', default='general', + help='Tag such as "academic", "business", "daily".') + active = fields.Boolean('Active', default=True) + + progress_ids = fields.One2many('encoach.vocab.progress', 'vocab_id', 'Progress') + + completion_count = fields.Integer('Completions', compute='_compute_stats') + learners_count = fields.Integer('Learners', compute='_compute_stats') + + @api.depends('progress_ids.completed') + def _compute_stats(self): + for rec in self: + rec.learners_count = len(rec.progress_ids) + rec.completion_count = len(rec.progress_ids.filtered(lambda p: p.completed)) + + @api.constrains('word') + def _check_word_unique(self): + for rec in self: + if not rec.word: + continue + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('word', '=ilike', rec.word.strip()), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError(f"Word '{rec.word}' already exists.") + + +class EncoachVocabProgress(models.Model): + _name = 'encoach.vocab.progress' + _description = 'Vocabulary Progress' + _order = 'last_reviewed desc' + + user_id = fields.Many2one('res.users', required=True, + default=lambda self: self.env.user.id, + ondelete='cascade') + vocab_id = fields.Many2one('encoach.vocab.item', required=True, ondelete='cascade') + completed = fields.Boolean('Completed', default=False) + mastery = fields.Selection([ + ('learning', 'Learning'), + ('familiar', 'Familiar'), + ('mastered', 'Mastered'), + ], default='learning') + last_reviewed = fields.Datetime('Last Reviewed') + review_count = fields.Integer('Review Count', default=0) + + @api.constrains('user_id', 'vocab_id') + def _check_unique_user_vocab(self): + for rec in self: + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('user_id', '=', rec.user_id.id), + ('vocab_id', '=', rec.vocab_id.id), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError("Progress already exists for this user + vocabulary item.") + + +class EncoachGrammarRule(models.Model): + _name = 'encoach.grammar.rule' + _description = 'Training Grammar Rule' + _order = 'level,name' + + name = fields.Char('Rule', required=True, index=True) + description = fields.Text('Description', required=True) + example = fields.Text('Example') + level = fields.Selection(CEFR_LEVELS, string='CEFR Level', default='B1', required=True) + category = fields.Char('Category', default='general', + help='Tag such as "tenses", "modals", "clauses".') + active = fields.Boolean('Active', default=True) + + progress_ids = fields.One2many('encoach.grammar.progress', 'rule_id', 'Progress') + + completion_count = fields.Integer('Completions', compute='_compute_stats') + learners_count = fields.Integer('Learners', compute='_compute_stats') + + @api.depends('progress_ids.completed') + def _compute_stats(self): + for rec in self: + rec.learners_count = len(rec.progress_ids) + rec.completion_count = len(rec.progress_ids.filtered(lambda p: p.completed)) + + @api.constrains('name') + def _check_rule_name_unique(self): + for rec in self: + if not rec.name: + continue + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('name', '=ilike', rec.name.strip()), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError(f"Grammar rule '{rec.name}' already exists.") + + +class EncoachGrammarProgress(models.Model): + _name = 'encoach.grammar.progress' + _description = 'Grammar Rule Progress' + _order = 'last_reviewed desc' + + user_id = fields.Many2one('res.users', required=True, + default=lambda self: self.env.user.id, + ondelete='cascade') + rule_id = fields.Many2one('encoach.grammar.rule', required=True, ondelete='cascade') + completed = fields.Boolean('Completed', default=False) + last_reviewed = fields.Datetime('Last Reviewed') + review_count = fields.Integer('Review Count', default=0) + + @api.constrains('user_id', 'rule_id') + def _check_unique_user_rule(self): + for rec in self: + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('user_id', '=', rec.user_id.id), + ('rule_id', '=', rec.rule_id.id), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError("Progress already exists for this user + grammar rule.") diff --git a/backend/custom_addons/encoach_lms_api/security/ir.model.access.csv b/backend/custom_addons/encoach_lms_api/security/ir.model.access.csv new file mode 100644 index 00000000..f6e23d74 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/security/ir.model.access.csv @@ -0,0 +1,27 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_lesson_all,encoach.lesson.all,model_encoach_lesson,base.group_user,1,1,1,1 +access_encoach_student_leave_type_all,encoach.student.leave.type.all,model_encoach_student_leave_type,base.group_user,1,1,1,1 +access_encoach_student_leave_all,encoach.student.leave.all,model_encoach_student_leave,base.group_user,1,1,1,1 +access_encoach_gradebook_all,encoach.gradebook.all,model_encoach_gradebook,base.group_user,1,1,1,1 +access_encoach_gradebook_line_all,encoach.gradebook.line.all,model_encoach_gradebook_line,base.group_user,1,1,1,1 +access_encoach_discussion_board_all,encoach.discussion.board.all,model_encoach_discussion_board,base.group_user,1,1,1,1 +access_encoach_discussion_post_all,encoach.discussion.post.all,model_encoach_discussion_post,base.group_user,1,1,1,1 +access_encoach_announcement_all,encoach.announcement.all,model_encoach_announcement,base.group_user,1,1,1,1 +access_encoach_message_all,encoach.message.all,model_encoach_message,base.group_user,1,1,1,1 +access_encoach_course_chapter_all,encoach.course.chapter.all,model_encoach_course_chapter,base.group_user,1,1,1,1 +access_encoach_chapter_material_all,encoach.chapter.material.all,model_encoach_chapter_material,base.group_user,1,1,1,1 +access_encoach_chapter_progress_all,encoach.chapter.progress.all,model_encoach_chapter_progress,base.group_user,1,1,1,1 +access_encoach_notification_all,encoach.notification.all,model_encoach_notification,base.group_user,1,1,1,1 +access_encoach_notification_rule_all,encoach.notification.rule.all,model_encoach_notification_rule,base.group_user,1,1,1,1 +access_encoach_notification_pref_all,encoach.notification.preference.all,model_encoach_notification_preference,base.group_user,1,1,1,1 +access_encoach_faq_category_all,encoach.faq.category.all,model_encoach_faq_category,base.group_user,1,1,1,1 +access_encoach_faq_item_all,encoach.faq.item.all,model_encoach_faq_item,base.group_user,1,1,1,1 +access_encoach_course_completion_all,encoach.course.completion.all,model_encoach_course_completion,base.group_user,1,1,1,1 +access_encoach_asset_all,encoach.asset.all,model_encoach_asset,base.group_user,1,1,1,1 +access_encoach_ticket_all,encoach.ticket.all,model_encoach_ticket,base.group_user,1,1,1,1 +access_encoach_vocab_item_all,encoach.vocab.item.all,model_encoach_vocab_item,base.group_user,1,1,1,1 +access_encoach_vocab_progress_all,encoach.vocab.progress.all,model_encoach_vocab_progress,base.group_user,1,1,1,1 +access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_rule,base.group_user,1,1,1,1 +access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1 +access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0 +access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1 diff --git a/backend/custom_addons/encoach_placement/__manifest__.py b/backend/custom_addons/encoach_placement/__manifest__.py index 1a3b7cc2..8e36291c 100644 --- a/backend/custom_addons/encoach_placement/__manifest__.py +++ b/backend/custom_addons/encoach_placement/__manifest__.py @@ -5,7 +5,7 @@ 'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping', 'author': 'EnCoach', 'license': 'LGPL-3', - 'depends': ['encoach_core', 'encoach_exam_template'], + 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_ai'], 'data': [ 'security/ir.model.access.csv', 'views/cat_session_views.xml', diff --git a/backend/custom_addons/encoach_placement/controllers/placement.py b/backend/custom_addons/encoach_placement/controllers/placement.py index b80c4af2..8dba9c64 100644 --- a/backend/custom_addons/encoach_placement/controllers/placement.py +++ b/backend/custom_addons/encoach_placement/controllers/placement.py @@ -8,6 +8,7 @@ from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( jwt_required, _json_response, _error_response, _get_json_body, _paginate ) +from odoo.addons.encoach_ai.services.cefr_mapper import theta_to_cefr as _theta_to_cefr _logger = logging.getLogger(__name__) @@ -15,18 +16,6 @@ LEARNING_RATE = 0.3 SEM_THRESHOLD = 0.3 MAX_QUESTIONS = 40 -THETA_CEFR = [ - (-3.0, 'pre_a1'), (-2.0, 'a1'), (-1.0, 'a2'), - (0.0, 'b1'), (1.0, 'b2'), (2.0, 'c1'), (3.0, 'c2'), -] - - -def _theta_to_cefr(theta): - for boundary, level in THETA_CEFR: - if theta <= boundary: - return level - return 'c2' - def _irt_probability(theta, a, b, c): """3PL IRT probability.""" diff --git a/backend/custom_addons/encoach_placement/services/cefr_mapper.py b/backend/custom_addons/encoach_placement/services/cefr_mapper.py index bce06815..face12bf 100644 --- a/backend/custom_addons/encoach_placement/services/cefr_mapper.py +++ b/backend/custom_addons/encoach_placement/services/cefr_mapper.py @@ -1,83 +1,20 @@ -class CefrMapper: - """Maps IRT theta values to CEFR levels and IELTS band scores.""" +"""Thin re-export of the canonical CEFR mapper for backward compat. - THETA_TO_CEFR = [ - (-4.0, -2.5, 'pre_a1'), - (-2.5, -1.5, 'a1'), - (-1.5, -0.5, 'a2'), - (-0.5, 0.5, 'b1'), - (0.5, 1.5, 'b2'), - (1.5, 2.5, 'c1'), - (2.5, 4.0, 'c2'), - ] +The canonical implementation lives in ``encoach_ai.services.cefr_mapper``. +This shim is kept so existing imports like +``from odoo.addons.encoach_placement.services.cefr_mapper import CefrMapper`` +continue to work. See P0.9 in §21 of docs/PROJECT_SUMMARY.md. +""" - CEFR_TO_BAND = { - 'pre_a1': 2.0, - 'a1': 3.0, - 'a2': 4.0, - 'b1': 5.0, - 'b2': 6.5, - 'c1': 7.5, - 'c2': 9.0, - } - - CEFR_LABELS = { - 'pre_a1': 'Pre-A1 (Beginner)', - 'a1': 'A1 (Elementary)', - 'a2': 'A2 (Pre-Intermediate)', - 'b1': 'B1 (Intermediate)', - 'b2': 'B2 (Upper-Intermediate)', - 'c1': 'C1 (Advanced)', - 'c2': 'C2 (Proficient)', - } - - @staticmethod - def theta_to_cefr(theta): - for low, high, level in CefrMapper.THETA_TO_CEFR: - if low <= theta < high: - return level - return 'c2' if theta >= 2.5 else 'pre_a1' - - @staticmethod - def theta_to_band(theta): - cefr = CefrMapper.theta_to_cefr(theta) - base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0) - - for low, high, level in CefrMapper.THETA_TO_CEFR: - if level == cefr: - range_width = high - low - if range_width > 0: - position = (theta - low) / range_width - else: - position = 0.5 - - cefr_list = list(CefrMapper.CEFR_TO_BAND.keys()) - idx = cefr_list.index(cefr) - next_band = CefrMapper.CEFR_TO_BAND.get( - cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0 - ) - - band = base_band + position * (next_band - base_band) - return round(band * 2) / 2 - - return base_band - - @staticmethod - def band_to_cefr(band): - if band < 2.5: - return 'pre_a1' - if band < 3.5: - return 'a1' - if band < 4.5: - return 'a2' - if band < 5.5: - return 'b1' - if band < 7.0: - return 'b2' - if band < 8.0: - return 'c1' - return 'c2' - - @staticmethod - def get_cefr_label(cefr_code): - return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code) +from odoo.addons.encoach_ai.services.cefr_mapper import ( # noqa: F401 + CefrMapper, + band_to_cefr, + theta_to_cefr, + theta_to_band, + cefr_to_band, + normalize_cefr, + cefr_label, + THETA_TO_CEFR, + CEFR_TO_BAND, + CEFR_LABELS, +) diff --git a/backend/custom_addons/encoach_resources/models/__init__.py b/backend/custom_addons/encoach_resources/models/__init__.py index 364a06e7..6bbf7a95 100644 --- a/backend/custom_addons/encoach_resources/models/__init__.py +++ b/backend/custom_addons/encoach_resources/models/__init__.py @@ -1 +1,2 @@ from . import resource +from . import resource_tag diff --git a/backend/custom_addons/encoach_resources/models/resource.py b/backend/custom_addons/encoach_resources/models/resource.py index 65f97339..81f2dec8 100644 --- a/backend/custom_addons/encoach_resources/models/resource.py +++ b/backend/custom_addons/encoach_resources/models/resource.py @@ -19,7 +19,16 @@ class EncoachResource(models.Model): ('rejected', 'Rejected'), ], default='approved') subject_id = fields.Many2one('encoach.subject', ondelete='set null') + domain_id = fields.Many2one('encoach.domain', ondelete='set null', string='Domain') topic_ids = fields.Many2many('encoach.topic') + learning_objective_ids = fields.Many2many( + 'encoach.learning.objective', 'encoach_resource_obj_rel', + 'resource_id', 'objective_id', string='Learning Objectives', + ) + tag_ids = fields.Many2many( + 'encoach.resource.tag', 'encoach_resource_tag_rel', + 'resource_id', 'tag_id', string='Tags', + ) file = fields.Binary(attachment=True) url = fields.Char() difficulty = fields.Selection([ @@ -39,6 +48,22 @@ class EncoachResource(models.Model): approved = fields.Boolean(default=False) ielts_certified = fields.Boolean(default=False) + def _get_course_count(self): + Mat = self.env.get('encoach.chapter.material') + if Mat is None: + for rec in self: + rec.course_count = 0 + return + Mat = self.env['encoach.chapter.material'].sudo() + for rec in self: + mats = Mat.search([('resource_id', '=', rec.id)]) + rec.course_count = len(set( + m.chapter_id.course_id.id for m in mats + if m.chapter_id and m.chapter_id.course_id + )) + + course_count = fields.Integer(compute='_get_course_count') + def to_api_dict(self): self.ensure_one() creator = self.creator_id @@ -48,8 +73,13 @@ class EncoachResource(models.Model): 'type': self.type or '', 'resource_type': self.type or 'document', 'subject_id': self.subject_id.id if self.subject_id else None, + 'subject_name': self.subject_id.name if self.subject_id else '', + 'domain_id': self.domain_id.id if self.domain_id else None, + 'domain_name': self.domain_id.name if self.domain_id else '', 'topic_ids': self.topic_ids.ids, 'topic_names': self.topic_ids.mapped('name'), + 'learning_objective_ids': self.learning_objective_ids.ids, + 'learning_objective_names': self.learning_objective_ids.mapped('name'), 'url': self.url or '', 'has_file': bool(self.file), 'difficulty': self.difficulty or '', @@ -61,8 +91,12 @@ class EncoachResource(models.Model): 'cefr_level': self.cefr_level or '', 'grammar_topic': self.grammar_topic or '', 'vocab_band': self.vocab_band or '', + 'tag_ids': self.tag_ids.ids, + 'tag_names': self.tag_ids.mapped('name'), + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in self.tag_ids], 'ai_generated': self.ai_generated, 'approved': self.approved, + 'course_count': self.course_count, 'created_at': self.create_date.isoformat() if self.create_date else '', 'file_name': self.name, } diff --git a/backend/custom_addons/encoach_resources/models/resource_tag.py b/backend/custom_addons/encoach_resources/models/resource_tag.py new file mode 100644 index 00000000..e57c800d --- /dev/null +++ b/backend/custom_addons/encoach_resources/models/resource_tag.py @@ -0,0 +1,30 @@ +from odoo import models, fields + + +class EncoachResourceTag(models.Model): + _name = 'encoach.resource.tag' + _description = 'Resource Tag' + _order = 'name' + + name = fields.Char(required=True, index=True) + color = fields.Char(size=20, help='Hex color code for UI badge, e.g. #3b82f6') + description = fields.Text() + active = fields.Boolean(default=True) + resource_ids = fields.Many2many( + 'encoach.resource', 'encoach_resource_tag_rel', + 'tag_id', 'resource_id', string='Resources', + ) + + _sql_constraints = [ + ('name_uniq', 'unique(name)', 'Tag name must be unique.'), + ] + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'name': self.name, + 'color': self.color or '#6b7280', + 'description': self.description or '', + 'resource_count': len(self.resource_ids), + } diff --git a/backend/custom_addons/encoach_resources/security/ir.model.access.csv b/backend/custom_addons/encoach_resources/security/ir.model.access.csv index be396306..45cb93a2 100644 --- a/backend/custom_addons/encoach_resources/security/ir.model.access.csv +++ b/backend/custom_addons/encoach_resources/security/ir.model.access.csv @@ -1,2 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_encoach_resource_user,encoach.resource.user,model_encoach_resource,base.group_user,1,1,1,1 +access_encoach_resource_tag_user,encoach.resource.tag.user,model_encoach_resource_tag,base.group_user,1,1,1,1 diff --git a/backend/custom_addons/encoach_scoring/controllers/exam_session.py b/backend/custom_addons/encoach_scoring/controllers/exam_session.py index 7466c941..9a99f21d 100644 --- a/backend/custom_addons/encoach_scoring/controllers/exam_session.py +++ b/backend/custom_addons/encoach_scoring/controllers/exam_session.py @@ -21,11 +21,7 @@ BAND_TO_CEFR = [ ] -def _band_to_cefr(band): - for threshold, level in BAND_TO_CEFR: - if band >= threshold: - return level - return 'pre_a1' +from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402 def _question_to_student_dict(q): @@ -186,21 +182,38 @@ class EncoachExamSessionController(http.Controller): q_dict = _question_to_student_dict(q) q_dict['saved_answer'] = saved_answers.get(q.id, '') questions.append(q_dict) - sections.append({ + sec_dict = { 'id': sec.id, 'title': sec.title, 'skill': sec.skill or '', + 'difficulty': sec.difficulty or '', 'time_limit_min': sec.time_limit_min or 0, + 'total_marks': sec.total_marks or 0, 'scoring_method': sec.scoring_method or 'auto', 'sequence': sec.sequence, 'questions': questions, - }) + } + if sec.passage_text: + sec_dict['passage_text'] = sec.passage_text + if sec.instructions_text: + sec_dict['instructions_text'] = sec.instructions_text + if sec.content_json: + try: + sec_dict['content'] = json.loads(sec.content_json) + except (json.JSONDecodeError, TypeError): + pass + sections.append(sec_dict) return _json_response({ 'attempt_id': attempt.id, 'exam_id': exam.id, 'exam_title': exam.title, + 'exam_mode': exam.exam_mode or 'official', 'total_time_min': exam.total_time_min or 0, + 'total_marks': exam.total_marks or 0, + 'grading_system': exam.grading_system or 'ielts', + 'access_type': exam.access_type or 'private', + 'randomize_questions': exam.randomize_questions or False, 'status': attempt.status, 'started_at': attempt.started_at, 'sections': sections, diff --git a/backend/custom_addons/encoach_scoring/controllers/grading.py b/backend/custom_addons/encoach_scoring/controllers/grading.py index 108dd147..3efcb3c0 100644 --- a/backend/custom_addons/encoach_scoring/controllers/grading.py +++ b/backend/custom_addons/encoach_scoring/controllers/grading.py @@ -16,11 +16,7 @@ BAND_TO_CEFR = [ ] -def _band_to_cefr(band): - for threshold, level in BAND_TO_CEFR: - if band >= threshold: - return level - return 'pre_a1' +from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402 def _recompute_bands(attempt): diff --git a/backend/custom_addons/encoach_scoring/models/student_attempt.py b/backend/custom_addons/encoach_scoring/models/student_attempt.py index 210a37d0..1d14fffb 100644 --- a/backend/custom_addons/encoach_scoring/models/student_attempt.py +++ b/backend/custom_addons/encoach_scoring/models/student_attempt.py @@ -1,4 +1,8 @@ -from odoo import models, fields +import logging + +from odoo import api, models, fields + +_logger = logging.getLogger(__name__) class EncoachStudentAttempt(models.Model): @@ -39,3 +43,31 @@ class EncoachStudentAttempt(models.Model): score_ids = fields.One2many('encoach.score', 'attempt_id') autosave_data = fields.Text() feedback_ids = fields.One2many('encoach.feedback', 'attempt_id') + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + 'encoach_student_attempt_student_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_student_attempt_student_status_idx " + "ON encoach_student_attempt (student_id, status)", + ), + ( + 'encoach_student_attempt_exam_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_student_attempt_exam_status_idx " + "ON encoach_student_attempt (exam_id, status) WHERE exam_id IS NOT NULL", + ), + ( + 'encoach_student_attempt_entity_released_idx', + "CREATE INDEX IF NOT EXISTS encoach_student_attempt_entity_released_idx " + "ON encoach_student_attempt (entity_id, released_at) " + "WHERE entity_id IS NOT NULL AND released_at IS NOT NULL", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res diff --git a/backend/custom_addons/encoach_taxonomy/controllers/taxonomy.py b/backend/custom_addons/encoach_taxonomy/controllers/taxonomy.py index 5479cd6c..d6119bfc 100644 --- a/backend/custom_addons/encoach_taxonomy/controllers/taxonomy.py +++ b/backend/custom_addons/encoach_taxonomy/controllers/taxonomy.py @@ -22,39 +22,336 @@ PARENT_FIELD_MAP = { } +def _subject_dict(s): + return { + 'id': s.id, + 'name': s.name, + 'code': s.code or '', + 'description': s.description or '', + 'is_active': s.active, + 'active': s.active, + 'domain_count': len(s.domain_ids), + 'topic_count': sum(len(d.topic_ids) for d in s.domain_ids), + 'course_count': s.course_count if hasattr(s, 'course_count') else 0, + 'resource_count': s.resource_count if hasattr(s, 'resource_count') else 0, + 'mastery_threshold': 80, + 'grading_scale': 'percentage', + 'diagnostic_config': { + 'questions_per_domain': 5, + 'total_question_cap': 30, + 'time_limit_minutes': 45, + 'starting_difficulty': 'medium', + 'mastery_per_correct': 10, + 'mastery_per_incorrect': -5, + }, + } + + +def _domain_dict(d): + return { + 'id': d.id, + 'name': d.name, + 'code': '', + 'subject_id': d.subject_id.id, + 'subject_name': d.subject_id.name, + 'sequence': d.sequence, + 'description': d.description or '', + 'topic_count': len(d.topic_ids), + } + + +def _topic_dict(t): + return { + 'id': t.id, + 'name': t.name, + 'code': '', + 'domain_id': t.domain_id.id, + 'domain_name': t.domain_id.name, + 'description': t.description or '', + 'difficulty_level': 'medium', + 'estimated_hours': 1, + 'prerequisite_ids': [], + 'prerequisite_names': [], + 'question_type_weights': {}, + 'objective_count': len(t.learning_objective_ids), + 'resource_count': t.resource_count if hasattr(t, 'resource_count') else 0, + 'chapter_count': t.chapter_count if hasattr(t, 'chapter_count') else 0, + } + + +def _objective_dict(o): + return { + 'id': o.id, + 'name': o.name, + 'topic_id': o.topic_id.id, + 'bloom_level': o.bloom_level or 'remember', + 'sequence': o.sequence if hasattr(o, 'sequence') else 0, + } + + class EncoachTaxonomyController(http.Controller): - # ------------------------------------------------------------------ - # GET /api/taxonomy/subjects - # ------------------------------------------------------------------ - @http.route('/api/taxonomy/subjects', type='http', auth='none', - methods=['GET'], csrf=False) + # ══════════════════════════════════════════════════════════════════ + # SUBJECTS /api/subjects + # ══════════════════════════════════════════════════════════════════ + + @http.route(['/api/subjects', '/api/taxonomy/subjects'], type='http', + auth='public', methods=['GET'], csrf=False) @jwt_required - def get_subjects(self, **kw): + def list_subjects(self, **kw): try: - Subject = request.env['encoach.subject'].sudo() - subjects = Subject.search([('active', '=', True)]) - - items = [] - for s in subjects: - items.append({ - 'id': s.id, - 'name': s.name, - 'code': s.code or '', - 'description': s.description or '', - 'domain_count': len(s.domain_ids), - }) - - return _json_response({'subjects': items}) - + recs = request.env['encoach.subject'].sudo().search([]) + items = [_subject_dict(s) for s in recs] + return _json_response({'items': items, 'data': items, + 'subjects': items, 'total': len(items)}) except Exception as e: - _logger.exception('get_subjects failed') + _logger.exception('list_subjects') return _error_response(str(e), 500) - # ------------------------------------------------------------------ - # GET /api/taxonomy/tree - # ------------------------------------------------------------------ - @http.route('/api/taxonomy/tree', type='http', auth='none', + @http.route('/api/subjects/', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_subject(self, sid, **kw): + try: + rec = request.env['encoach.subject'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Subject not found', 404) + return _json_response({'data': _subject_dict(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_subject(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', ''), 'code': body.get('code', '')} + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.subject'].sudo().create(vals) + return _json_response(_subject_dict(rec), 201) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_subject(self, sid, **kw): + try: + rec = request.env['encoach.subject'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'description'): + if k in body: + vals[k] = body[k] + if 'is_active' in body: + vals['active'] = bool(body['is_active']) + if vals: + rec.write(vals) + return _json_response(_subject_dict(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_subject(self, sid, **kw): + try: + rec = request.env['encoach.subject'].sudo().browse(sid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects//taxonomy', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_subject_taxonomy(self, sid, **kw): + try: + s = request.env['encoach.subject'].sudo().browse(sid) + if not s.exists(): + return _error_response('Subject not found', 404) + domains = [] + for d in s.domain_ids: + topics = [] + for t in d.topic_ids: + objectives = [_objective_dict(o) for o in t.learning_objective_ids] + td = _topic_dict(t) + td['objectives'] = objectives + topics.append(td) + dd = _domain_dict(d) + dd['topics'] = topics + domains.append(dd) + result = _subject_dict(s) + result['domains'] = domains + return _json_response({'data': {'subject': _subject_dict(s), 'domains': domains}}) + except Exception as e: + _logger.exception('get_subject_taxonomy') + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # DOMAINS /api/domains + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/domains', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_domains(self, **kw): + try: + domain_filter = [] + if kw.get('subject_id'): + domain_filter.append(('subject_id', '=', int(kw['subject_id']))) + recs = request.env['encoach.domain'].sudo().search(domain_filter) + items = [_domain_dict(d) for d in recs] + return _json_response({'items': items, 'data': items, + 'total': len(items)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_domain(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'subject_id': int(body['subject_id']), + } + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.domain'].sudo().create(vals) + return _json_response(_domain_dict(rec), 201) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_domain(self, did, **kw): + try: + rec = request.env['encoach.domain'].sudo().browse(did) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'description'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response(_domain_dict(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_domain(self, did, **kw): + try: + rec = request.env['encoach.domain'].sudo().browse(did) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains//ai-suggest', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def ai_suggest_topics(self, did, **kw): + try: + domain = request.env['encoach.domain'].sudo().browse(did) + if not domain.exists(): + return _error_response('Domain not found', 404) + suggestions = [ + {'name': f'{domain.name} - Fundamentals', 'difficulty_level': 'easy', 'estimated_hours': 2}, + {'name': f'{domain.name} - Intermediate Concepts', 'difficulty_level': 'medium', 'estimated_hours': 3}, + {'name': f'{domain.name} - Advanced Topics', 'difficulty_level': 'hard', 'estimated_hours': 4}, + ] + return _json_response({'suggestions': suggestions}) + except Exception as e: + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # TOPICS /api/topics + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/topics', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_topics(self, **kw): + try: + domain_filter = [] + if kw.get('domain_id'): + domain_filter.append(('domain_id', '=', int(kw['domain_id']))) + if kw.get('subject_id'): + domains = request.env['encoach.domain'].sudo().search([('subject_id', '=', int(kw['subject_id']))]) + domain_filter.append(('domain_id', 'in', domains.ids)) + recs = request.env['encoach.topic'].sudo().search(domain_filter) + items = [_topic_dict(t) for t in recs] + return _json_response({'items': items, 'data': items, + 'total': len(items)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/topics', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_topic(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'domain_id': int(body['domain_id']), + } + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.topic'].sudo().create(vals) + return _json_response(_topic_dict(rec), 201) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/topics/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_topic(self, tid, **kw): + try: + rec = request.env['encoach.topic'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'description'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response(_topic_dict(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/topics/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_topic(self, tid, **kw): + try: + rec = request.env['encoach.topic'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # Legacy /api/taxonomy/* routes + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/taxonomy/tree', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def get_tree(self, **kw): diff --git a/backend/custom_addons/encoach_taxonomy/models/learning_objective.py b/backend/custom_addons/encoach_taxonomy/models/learning_objective.py index 23a1e0e4..59c77516 100644 --- a/backend/custom_addons/encoach_taxonomy/models/learning_objective.py +++ b/backend/custom_addons/encoach_taxonomy/models/learning_objective.py @@ -9,6 +9,14 @@ class EncoachLearningObjective(models.Model): name = fields.Char(required=True, index=True) topic_id = fields.Many2one('encoach.topic', required=True, ondelete='cascade', index=True) description = fields.Text() + bloom_level = fields.Selection([ + ('remember', 'Remember'), + ('understand', 'Understand'), + ('apply', 'Apply'), + ('analyze', 'Analyze'), + ('evaluate', 'Evaluate'), + ('create', 'Create'), + ], default='remember') cefr_level = fields.Selection([ ('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'), ('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'), diff --git a/backend/custom_addons/encoach_taxonomy/models/subject.py b/backend/custom_addons/encoach_taxonomy/models/subject.py index ed3eb8bd..6f031f07 100644 --- a/backend/custom_addons/encoach_taxonomy/models/subject.py +++ b/backend/custom_addons/encoach_taxonomy/models/subject.py @@ -13,6 +13,18 @@ class EncoachSubject(models.Model): active = fields.Boolean(default=True) domain_ids = fields.One2many('encoach.domain', 'subject_id', string='Domains') + course_count = fields.Integer(compute='_compute_counts') + resource_count = fields.Integer(compute='_compute_counts') + + def _compute_counts(self): + for rec in self: + rec.course_count = self.env['op.course'].sudo().search_count( + [('encoach_subject_id', '=', rec.id)] + ) + rec.resource_count = self.env['encoach.resource'].sudo().search_count( + [('subject_id', '=', rec.id)] + ) + def to_api_dict(self): self.ensure_one() return { @@ -21,4 +33,6 @@ class EncoachSubject(models.Model): 'code': self.code or '', 'description': self.description or '', 'active': self.active, + 'course_count': self.course_count, + 'resource_count': self.resource_count, } diff --git a/backend/custom_addons/encoach_taxonomy/models/topic.py b/backend/custom_addons/encoach_taxonomy/models/topic.py index 00fd7cf9..da00fe34 100644 --- a/backend/custom_addons/encoach_taxonomy/models/topic.py +++ b/backend/custom_addons/encoach_taxonomy/models/topic.py @@ -13,6 +13,18 @@ class EncoachTopic(models.Model): active = fields.Boolean(default=True) learning_objective_ids = fields.One2many('encoach.learning.objective', 'topic_id', string='Learning Objectives') + resource_count = fields.Integer(compute='_compute_counts') + chapter_count = fields.Integer(compute='_compute_counts') + + def _compute_counts(self): + for rec in self: + rec.resource_count = self.env['encoach.resource'].sudo().search_count( + [('topic_ids', 'in', [rec.id])] + ) + rec.chapter_count = self.env['encoach.course.chapter'].sudo().search_count( + [('topic_id', '=', rec.id)] + ) + def to_api_dict(self): self.ensure_one() return { @@ -22,4 +34,6 @@ class EncoachTopic(models.Model): 'domain_name': self.domain_id.name, 'description': self.description or '', 'active': self.active, + 'resource_count': self.resource_count, + 'chapter_count': self.chapter_count, } diff --git a/backend/custom_addons/encoach_vector/models/embedding.py b/backend/custom_addons/encoach_vector/models/embedding.py index 4877063f..fbcbe8bb 100644 --- a/backend/custom_addons/encoach_vector/models/embedding.py +++ b/backend/custom_addons/encoach_vector/models/embedding.py @@ -22,14 +22,23 @@ class EncoachEmbedding(models.Model): ('topic', 'Topic'), ('feedback', 'Feedback'), ('generation_log', 'Generation Log'), + ('material', 'Course Material'), ], required=True, index=True) content_id = fields.Integer(required=True, index=True) content_text = fields.Text() metadata_json = fields.Text(default='{}') + course_id = fields.Integer(string='Course ID', index=True) + subject_id = fields.Integer(string='Subject ID', index=True) + entity_id = fields.Integer(string='Entity ID', index=True) + taxonomy = fields.Char(string='Taxonomy', index=True) + content_hash = fields.Char(string='Content SHA256', index=True) + chunk_index = fields.Integer(string='Chunk #', default=0) + chunk_total = fields.Integer(string='Total Chunks', default=1) + _content_unique = models.Constraint( - 'UNIQUE(content_type, content_id)', - 'Each content item can only have one embedding.', + 'UNIQUE(content_type, content_id, chunk_index)', + 'Each content item chunk can only have one embedding.', ) @api.model @@ -81,27 +90,46 @@ class EncoachEmbedding(models.Model): return index_all(self.env) @api.model - def similarity_search(self, query_vector, *, content_type=None, limit=10): - """Find similar embeddings using cosine distance.""" + def similarity_search(self, query_vector, *, content_type=None, limit=10, + course_id=None, subject_id=None, entity_id=None, + taxonomy=None): + """Find similar embeddings using cosine distance. + + Optional metadata filters (course/subject/entity/taxonomy) are applied + as SQL equality predicates to narrow the candidate set before ranking. + """ vec_str = '[' + ','.join(str(v) for v in query_vector) + ']' - where = "WHERE embedding IS NOT NULL" - params = [vec_str, limit] + conditions = ["embedding IS NOT NULL"] + extra_params = [] if content_type: - where += " AND content_type = %s" - params = [vec_str, content_type, limit] + conditions.append("content_type = %s") + extra_params.append(content_type) + if course_id: + conditions.append("course_id = %s") + extra_params.append(int(course_id)) + if subject_id: + conditions.append("subject_id = %s") + extra_params.append(int(subject_id)) + if entity_id: + conditions.append("entity_id = %s") + extra_params.append(int(entity_id)) + if taxonomy: + conditions.append("taxonomy = %s") + extra_params.append(taxonomy) + where = "WHERE " + " AND ".join(conditions) query = f""" SELECT id, content_type, content_id, content_text, metadata_json, + course_id, subject_id, entity_id, taxonomy, + chunk_index, chunk_total, 1 - (embedding <=> %s::vector) AS similarity FROM encoach_embedding {where} ORDER BY embedding <=> %s::vector LIMIT %s """ - if content_type: - self.env.cr.execute(query, (vec_str, content_type, vec_str, limit)) - else: - self.env.cr.execute(query, (vec_str, vec_str, limit)) + params = [vec_str] + extra_params + [vec_str, limit] + self.env.cr.execute(query, params) results = [] for row in self.env.cr.dictfetchall(): @@ -116,6 +144,12 @@ class EncoachEmbedding(models.Model): 'content_id': row['content_id'], 'text': row['content_text'], 'metadata': metadata, + 'course_id': row['course_id'] or None, + 'subject_id': row['subject_id'] or None, + 'entity_id': row['entity_id'] or None, + 'taxonomy': row['taxonomy'] or None, + 'chunk_index': row['chunk_index'], + 'chunk_total': row['chunk_total'], 'similarity': round(row['similarity'], 4), }) return results diff --git a/backend/custom_addons/encoach_vector/services/embedding_service.py b/backend/custom_addons/encoach_vector/services/embedding_service.py index 966e7b75..84d094a6 100644 --- a/backend/custom_addons/encoach_vector/services/embedding_service.py +++ b/backend/custom_addons/encoach_vector/services/embedding_service.py @@ -1,13 +1,81 @@ """Embedding service — encode text and manage vector storage.""" +import hashlib import json import logging +import re import time _logger = logging.getLogger(__name__) _model_instance = None +CHUNK_CHAR_LIMIT = 2000 +CHUNK_OVERLAP = 200 +_PARA_SPLIT = re.compile(r"\n\s*\n+") +_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+") + + +def _chunk_text(text, *, limit=CHUNK_CHAR_LIMIT, overlap=CHUNK_OVERLAP): + """Split long text into semantically-aware chunks of at most ``limit`` chars. + + Preserves paragraph and sentence boundaries where possible. Each chunk + after the first retains ``overlap`` chars of tail context for continuity. + Short inputs return a single-chunk list unchanged. + """ + text = (text or "").strip() + if not text: + return [] + if len(text) <= limit: + return [text] + + chunks = [] + buf = "" + for para in _PARA_SPLIT.split(text): + para = para.strip() + if not para: + continue + candidate = (buf + "\n\n" + para) if buf else para + if len(candidate) <= limit: + buf = candidate + continue + if buf: + chunks.append(buf) + buf = "" + if len(para) <= limit: + buf = para + continue + for sent in _SENT_SPLIT.split(para): + sent = sent.strip() + if not sent: + continue + cand = (buf + " " + sent) if buf else sent + if len(cand) <= limit: + buf = cand + continue + if buf: + chunks.append(buf) + if len(sent) <= limit: + buf = sent + else: + for i in range(0, len(sent), limit - overlap): + chunks.append(sent[i:i + limit]) + buf = "" + if buf: + chunks.append(buf) + + if overlap > 0 and len(chunks) > 1: + with_overlap = [chunks[0]] + for i in range(1, len(chunks)): + tail = chunks[i - 1][-overlap:] + with_overlap.append((tail + " " + chunks[i]).strip()) + chunks = with_overlap + return chunks + + +def _sha256(text): + return hashlib.sha256((text or "").encode("utf-8")).hexdigest() + def _get_model(): """Lazy-load the sentence-transformers model (cached across calls).""" @@ -47,42 +115,77 @@ class EmbeddingService: return [e.tolist() for e in embeddings] def upsert(self, content_type, content_id, text, metadata=None): - """Encode and store (or update) a single embedding. + """Encode and store (or update) one embedding per text chunk. + + Long ``text`` is split by :func:`_chunk_text` into chunks of at most + ``CHUNK_CHAR_LIMIT`` chars. Each chunk is stored as a separate row + keyed by ``(content_type, content_id, chunk_index)``. Dedicated + metadata columns (course_id, subject_id, entity_id, taxonomy, + content_hash) are populated from ``metadata`` when present. Returns: - encoach.embedding record + list of encoach.embedding records (one per chunk). Empty list + if input text is blank. """ if not text or not text.strip(): - return None + return [] - existing = self.Embedding.search([ + metadata = dict(metadata or {}) + chunks = _chunk_text(text) + if not chunks: + return [] + + vectors = self.encode(chunks) + existing_all = self.Embedding.search([ ('content_type', '=', content_type), ('content_id', '=', content_id), - ], limit=1) + ]) + by_idx = {rec.chunk_index: rec for rec in existing_all} + stale = [rec for rec in existing_all if rec.chunk_index >= len(chunks)] + if stale: + self.Embedding.browse([r.id for r in stale]).unlink() - vectors = self.encode([text]) - meta_str = json.dumps(metadata or {}) + records = [] + total = len(chunks) + for idx, (chunk, vec) in enumerate(zip(chunks, vectors)): + chunk_hash = _sha256(chunk) + base_vals = { + 'content_text': chunk[:10000], + 'metadata_json': json.dumps(metadata), + 'course_id': int(metadata.get('course_id') or 0) or False, + 'subject_id': int(metadata.get('subject_id') or 0) or False, + 'entity_id': int(metadata.get('entity_id') or 0) or False, + 'taxonomy': metadata.get('taxonomy') or False, + 'content_hash': chunk_hash, + 'chunk_index': idx, + 'chunk_total': total, + } + rec = by_idx.get(idx) + if rec: + if rec.content_hash != chunk_hash or rec.chunk_total != total: + rec.write(base_vals) + rec.set_embedding(vec) + else: + rec.write({k: v for k, v in base_vals.items() + if k in ('metadata_json', 'chunk_total')}) + else: + rec = self.Embedding.create({ + 'content_type': content_type, + 'content_id': content_id, + **base_vals, + }) + rec.set_embedding(vec) + records.append(rec) + return records - if existing: - existing.write({ - 'content_text': text[:10000], - 'metadata_json': meta_str, - }) - existing.set_embedding(vectors[0]) - return existing - - record = self.Embedding.create({ - 'content_type': content_type, - 'content_id': content_id, - 'content_text': text[:10000], - 'metadata_json': meta_str, - }) - record.set_embedding(vectors[0]) - return record - - def search(self, query, *, content_type=None, limit=10): + def search(self, query, *, content_type=None, limit=10, + course_id=None, subject_id=None, entity_id=None, + taxonomy=None): """Semantic search — encode query and find similar content. + Optional metadata filters narrow the candidate pool to a specific + course / subject / entity / taxonomy before ranking. + Returns: list of dicts with text, metadata, similarity score """ @@ -95,6 +198,10 @@ class EmbeddingService: vectors[0], content_type=content_type, limit=limit, + course_id=course_id, + subject_id=subject_id, + entity_id=entity_id, + taxonomy=taxonomy, ) latency = int((time.time() - t0) * 1000) _logger.info("Vector search for '%s' returned %d results in %dms", diff --git a/backend/custom_addons/encoach_vector/services/indexer.py b/backend/custom_addons/encoach_vector/services/indexer.py index 3e74bd6d..c5237e41 100644 --- a/backend/custom_addons/encoach_vector/services/indexer.py +++ b/backend/custom_addons/encoach_vector/services/indexer.py @@ -11,6 +11,8 @@ MODEL_CONFIG = [ 'text_field': 'name', 'description_field': 'description', 'metadata_fields': [], + 'course_field': 'id', + 'subject_field': 'subject_id', }, { 'model': 'encoach.resource', @@ -18,13 +20,19 @@ MODEL_CONFIG = [ 'text_field': 'name', 'description_field': 'content', 'metadata_fields': ['type', 'cefr_level', 'difficulty'], + 'course_field': 'course_id', + 'subject_field': 'subject_id', + 'entity_field': 'entity_id', + 'taxonomy_field': 'type', }, { 'model': 'encoach.question', 'content_type': 'question', - 'text_field': 'name', + 'text_field': 'stem', 'description_field': None, 'metadata_fields': ['question_type', 'difficulty', 'skill'], + 'subject_field': 'subject_id', + 'taxonomy_field': 'skill', }, { 'model': 'encoach.course.module', @@ -32,6 +40,8 @@ MODEL_CONFIG = [ 'text_field': 'name', 'description_field': 'description', 'metadata_fields': ['skill'], + 'course_field': 'course_id', + 'taxonomy_field': 'skill', }, { 'model': 'encoach.ai.generation.log', @@ -39,10 +49,29 @@ MODEL_CONFIG = [ 'text_field': 'brief', 'description_field': 'generated_content', 'metadata_fields': ['course_type', 'status'], + 'taxonomy_field': 'course_type', + }, + { + 'model': 'encoach.chapter.material', + 'content_type': 'material', + 'text_field': 'name', + 'description_field': 'description', + 'metadata_fields': ['type'], + 'course_field': 'course_id', + 'taxonomy_field': 'type', }, ] +def _safe_id(record, field): + if not field or not hasattr(record, field): + return None + val = getattr(record, field) + if hasattr(val, 'id'): + return val.id or None + return int(val) if val else None + + def _get_text(record, config): """Extract indexable text from a record.""" parts = [] @@ -62,13 +91,32 @@ def _get_text(record, config): def _get_metadata(record, config): - """Extract metadata dict from a record.""" + """Extract metadata dict from a record, including structured + course/subject/entity/taxonomy fields used by the RAG retriever.""" meta = {} for f in config.get('metadata_fields', []): if hasattr(record, f): val = getattr(record, f) if val: meta[f] = str(val) if not isinstance(val, (int, float, bool)) else val + + course_id = _safe_id(record, config.get('course_field')) + if course_id: + meta['course_id'] = course_id + subject_id = _safe_id(record, config.get('subject_field')) + if subject_id: + meta['subject_id'] = subject_id + entity_id = _safe_id(record, config.get('entity_field')) + if entity_id: + meta['entity_id'] = entity_id + + taxonomy_field = config.get('taxonomy_field') + if taxonomy_field and hasattr(record, taxonomy_field): + tx = getattr(record, taxonomy_field) + if tx: + meta['taxonomy'] = ( + str(tx.display_name) if hasattr(tx, 'display_name') else str(tx) + ) return meta diff --git a/docker-compose.yml b/docker-compose.yml index d32c8c05..5084984d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: db: image: postgres:16 - container_name: backend-v2-db + container_name: encoach-v4-db restart: unless-stopped environment: POSTGRES_USER: odoo @@ -18,7 +18,7 @@ services: odoo: build: . image: encoach-backend:latest - container_name: backend-v2-odoo + container_name: encoach-v4-odoo restart: unless-stopped depends_on: db: @@ -28,9 +28,15 @@ services: volumes: - odoo-web-data:/var/lib/odoo - ./odoo-docker.conf:/etc/odoo/odoo.conf:ro - - ./new_project/custom_addons:/mnt/custom_addons:ro - - ./new_project/enterprise-19:/mnt/enterprise:ro - - ./new_project/openeducat_erp-19.0:/mnt/openeducat:ro + # Mount the whole monorepo so both custom addons and OpenEduCat are + # available via /mnt/extra-addons (see odoo-docker.conf). + - .:/mnt/extra-addons:ro + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8069/api/health || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 60s volumes: odoo-db-data: diff --git a/docs/PROJECT_SUMMARY.md b/docs/PROJECT_SUMMARY.md new file mode 100644 index 00000000..1033de03 --- /dev/null +++ b/docs/PROJECT_SUMMARY.md @@ -0,0 +1,1451 @@ +# EnCoach Platform — Project Summary + +> Last updated: 2026-04-19 | **Canonical repos: [`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4) (backend) + [`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4) (frontend), branch `main`.** +> +> This workspace (`odoo19/`) is a **developer monorepo / working tree only** — it conveniently contains both halves side-by-side for local development and testing. The two split repos above are the **authoritative origins** for each half: every change must be published to them (via `git subtree split + push`) before the team lead can deploy. See **§6 Git Remotes & Repositories** for the exact workflow. + +> **Latest events:** +> - **2026-04-19 (reports section):** Built the Reports section end-to-end — the three pages `/admin/student-performance`, `/admin/stats-corporate`, `/admin/record` (previously pure hardcoded-array mocks) are now wired to real aggregated data from `encoach.student.attempt`. New `/api/reports/{student-performance,stats-corporate,record,filters}` controller (`encoach_lms_api/controllers/reports.py`) does the rollups: per-student band averages + CEFR, per-module corporate charts, trend / distribution / entity comparison, and per-user attempt history with search / level / entity / period filters and CSV export. New `seed_reports.py` completes in-progress attempts and backfills six months of historical attempts so the trend chart and KPI cards are meaningful. 25/25 API smoke passing (`test_reports_flows.py`), 24/24 Configuration + 29/29 Support + 26/26 Training regressions still green, all three pages verified live in-browser with 28 real attempts showing across 4 tabs. See §20. +> - **2026-04-19 (remote rename):** Aligned local remote names with the new doctrine — `backend-v4 → origin-backend`, `frontend-v4 → origin-frontend`, `origin → mirror-monorepo`. The `v4` branch now tracks `mirror-monorepo/v4`. All publishing commands in §6 updated. No remote URLs changed. +> - **2026-04-19 (repos-of-record reorganization):** Declared `encoach_backend_v4` and `encoach_frontend_v4` as the canonical origins for their respective halves; the monorepo `encoach_backend_new_v2/v4` is now a secondary working-tree mirror kept only for history/convenience. §6 rewritten around this model. +> - **2026-04-19 (release to VPS repos):** Pushed the accumulated institutional + support + training work. Frontend canonical → `encoach_frontend_v4/main` (`b78124bb..435930a8`, 403 files, clean fast-forward). Backend canonical → `encoach_backend_v4/main` (`74d83af5..6ec68160`, 420 files, clean fast-forward). Monorepo mirror → `mirror-monorepo/v4` (`98b9837a`). `.gitignore` now excludes `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/` so the 674 MB of local pgdata backups never leak into a push. See §6. +> - **2026-04-18 (training section):** Built the Training section end-to-end — the `Vocabulary` and `Grammar` pages (previously pure hard-coded mocks) are now wired to real Odoo data with CRUD, search, level filtering, and per-user progress tracking. New `encoach.vocab.item` / `encoach.vocab.progress` / `encoach.grammar.rule` / `encoach.grammar.progress` models + `/api/training/vocabulary` and `/api/training/grammar` controllers. 26/26 API write-flow actions passing (`test_training_flows.py`); both pages verified live in-browser. See §19. +> - **2026-04-18 (support section):** Built the Support section end-to-end — the `Tickets`, `Payment Record`, and `Settings` pages are now wired to real Odoo data (they were previously mocks/404s). New `encoach.ticket` model + full CRUD controller, `/api/payment-records` deriving from `op.student.fees.details` + `account.move`, `/api/codes` + `/api/packages` + `/api/grading-config` powering the Settings tabs. 29/29 API write-flow actions passing (`test_support_flows.py`); all 3 pages verified live in-browser. See §18. +> - **2026-04-18 (institutional pages write-flow):** API-level write-flow sweep across all 14 institutional admin pages — **44/44 actions passing** (CREATE / EDIT / DELETE / WORKFLOW). Numerous backend gaps fixed: missing DELETE routes added (admission-registers, admissions, result-templates, student-leaves), lesson/library detail endpoints added, custom `encoach.asset` model introduced to replace the missing `op.asset`, marksheet generate endpoint aliased for Odoo 19's `generate_result`, fees `create-invoice` auto-wires the product income account via Odoo 19's JSONB property storage, academic-year DELETE now cascades dependent terms. See §17. +> - **2026-04-18 (browser E2E):** Full end-to-end test across admin, teacher, student user types. All critical pages verified working in browser. Two backend bugs fixed: (1) `/api/approval-workflows` returned 500 because raw SQL referenced a table that doesn't exist — now gracefully returns empty list; (2) `DELETE /api/courses/` returned opaque 500 when course had enrollments — now returns 409 with a clear message and supports `?force=true` for cascade-delete. See §16. + +--- + +## 1. Project Overview + +EnCoach is an AI-powered online learning and examination platform built on **Odoo 19** (backend) and **React + TypeScript + Vite** (frontend). It supports IELTS and general English exam generation, student exam sessions, auto-scoring, adaptive learning, and course creation. + +--- + +## 2. Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Backend** | Odoo 19, Python 3.12, PostgreSQL 18 | +| **Frontend** | React 18, TypeScript, Vite 5, Tailwind CSS, Shadcn UI | +| **State Management** | `@tanstack/react-query` (mutations + queries) | +| **Auth** | JWT (issued by Odoo, validated via `validate_token` helper) | +| **AI** | OpenAI API (with fallback templates when API key not configured) | +| **Database** | `encoach_v2` (PostgreSQL 18.3) | + +--- + +## 3. User Credentials + +Admin/teacher users use password **`admin`**. Student passwords were reset to **`student123`** during end-to-end testing. + +| ID | Login | Name | Type | Password | +|----|-------|------|------|----------| +| 2 | `admin` | Administrator | superadmin | `admin` | +| 5 | `admin@encoach.test` | Admin User | admin | `admin` | +| 6 | `sarah@encoach.test` | Sarah Ahmed | student | **`student123`** | +| 7 | `omar@encoach.test` | Omar Khan | student | **`student123`** | +| 8 | `layla@encoach.test` | Layla Nasser | student | **`student123`** | +| 9 | `khalid@encoach.test` | Dr. Khalid | teacher | `admin` | +| 10 | `fatima@encoach.test` | Ms. Fatima | teacher | `admin` | + +> **Note:** Student passwords were bulk-reset to `student123` via a direct `psycopg2` + `passlib` script during testing. If a student can't login, reset their password in psql using `passlib.context.CryptContext(['pbkdf2_sha512'])`. + +### Login API + +```bash +# Admin login +curl -X POST http://localhost:8069/api/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin","password":"admin"}' + +# Student login +curl -X POST http://localhost:8069/api/login \ + -H "Content-Type: application/json" \ + -d '{"email":"sarah@encoach.test","password":"student123"}' +``` + +Returns `{ token, user, permissions }`. The `token` (JWT) is used as `Authorization: Bearer ` for all subsequent API calls. + +--- + +## 4. How to Start the Project + +### Prerequisites +- PostgreSQL 18 binary at `/Users/yamenahmad/micromamba/envs/odoo19/bin/` +- Python 3.12 (conda env at `.conda-envs/odoo19/` or micromamba) +- Node.js 20 at `/Users/yamenahmad/.local/node/bin/` + +### Start PostgreSQL + +```bash +PGBIN="/Users/yamenahmad/micromamba/envs/odoo19/bin" +PGDATA="/Users/yamenahmad/projects2026/odoo/odoo19/pgdata" +"$PGBIN/pg_ctl" -D "$PGDATA" -l "$PGDATA/logfile" start +``` + +> **Important:** The pgdata was initialized with PG 18. The conda env has PG 17.6, which is **incompatible**. Always use the micromamba PG 18 binary above. +> +> If PG fails with config errors, check `pgdata/logfile`. The parameter `autovacuum_worker_slots` (PG 18-only) was commented out in `pgdata/postgresql.conf` line 687 for compatibility. + +### Start Odoo Backend + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19 +./run.sh +``` + +Runs at **http://127.0.0.1:8069** + +> **Odoo config** (`odoo.conf`) must have `db_name = encoach_v2` and `dbfilter = ^encoach_v2$`, otherwise API routes return 404 ("No database selected"). + +### Start Frontend + +```bash +export PATH="/Users/yamenahmad/.local/node/bin:$PATH" +cd /Users/yamenahmad/projects2026/odoo/odoo19/frontend +npm run dev +``` + +Runs at **http://localhost:8080** — Vite proxies `/api` requests to Odoo `:8069`. + +### Stop PostgreSQL + +```bash +"$PGBIN/pg_ctl" -D "$PGDATA" stop +``` + +--- + +## 5. Project Structure + +``` +odoo19/ +├── backend/ +│ ├── custom_addons/ # All EnCoach Odoo modules +│ │ ├── encoach_api/ # Auth (login/logout/user) + base helpers +│ │ ├── encoach_ai/ # AI generation, exam generation/submit +│ │ ├── encoach_ai_course/ # AI course creation pipelines +│ │ ├── encoach_adaptive/ # Adaptive learning engine +│ │ ├── encoach_core/ # Core models (user, entity, role, permission) +│ │ ├── encoach_exam_template/ # Exam CRUD, sessions, rubrics, assignments, schedules +│ │ ├── encoach_scoring/ # Grading queue, score release +│ │ ├── encoach_taxonomy/ # Subject/topic tree +│ │ ├── encoach_lms_api/ # LMS: courses, chapters, materials, progress, enrollment +│ │ ├── encoach_course_gen/ # Course generation +│ │ ├── encoach_branding/ # Entity branding +│ │ ├── encoach_signup/ # Registration, email verification, onboarding +│ │ ├── encoach_placement/ # Placement test +│ │ └── ... +│ └── openeducat_erp-19.0/ # OpenEduCat base modules +├── frontend/ +│ ├── src/ +│ │ ├── pages/ # Route pages (admin/, student/, etc.) +│ │ ├── components/ # Reusable UI components +│ │ ├── services/ # API service classes +│ │ ├── hooks/ # React Query hooks +│ │ ├── lib/ # api-client, utils +│ │ └── types/ # TypeScript type definitions +│ ├── .env.development # VITE_API_BASE_URL=/api +│ └── .env.production # VITE_API_BASE_URL=https://api.encoach.com/api +├── docs/ # Documentation +├── odoo/ # Odoo 19 source code +├── odoo.conf # Odoo configuration +├── pgdata/ # PostgreSQL data directory (PG 18) +├── run.sh # Start Odoo +├── start.sh # Start PG + Odoo +└── stop.sh # Stop services +``` + +--- + +## 6. Git Remotes & Repositories + +> **Canonical (repos-of-record):** +> - Backend → **[`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4.git)** — branch `main` +> - Frontend → **[`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4.git)** — branch `main` +> +> These are the repositories the team lead (Talal / devops) clones on the VPS and +> builds with `docker compose up -d --build`. **All production / staging code +> lives here.** +> +> This workspace (`odoo19/`) is a **developer monorepo / working tree** — a +> convenience folder that holds both halves side-by-side so a single developer +> can run the full stack locally. It is **not** the source of truth: every change +> must be published to the two canonical repos above via `git subtree split + push` +> before it counts as merged. + +### 6.1 Repository Layout + +``` +odoo19/ ← developer working tree (NOT source of truth) +├── backend/ → published to encoach_backend_v4/main (authoritative) +│ ├── custom_addons/ +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── requirements.txt +├── frontend/ → published to encoach_frontend_v4/main (authoritative) +│ ├── src/ +│ ├── public/ +│ ├── Dockerfile, vite.config.ts, package.json, … +└── docs/ → lives only in this workspace +``` + +### 6.2 Remotes Configured on This Clone + +Listed in order of importance. The two bold rows are the **repos of record**; +everything else is a secondary mirror or legacy. + +| Remote | URL | Role | +|--------|-----|------| +| **`origin-backend`** | `https://git.albousalh.com/devops/encoach_backend_v4.git` | **Canonical backend** — branch `main`. VPS deploy target. | +| **`origin-frontend`** | `https://git.albousalh.com/devops/encoach_frontend_v4.git` | **Canonical frontend** — branch `main`. VPS deploy target. | +| `mirror-monorepo` | `https://git.albousalh.com/devops/encoach_backend_new_v2.git` | Secondary full-tree mirror (branch `v4` tracked here). Useful for history/backup and for docs that live only in the workspace root (e.g. this file). **Not** the source of truth. | +| `ip-origin` | `https://5.189.151.117/devops/encoach_backend_new_v2.git` | IP fallback for `mirror-monorepo` when DNS is flaky. | +| `backend-v3` | `https://git.albousalh.com/devops/encoach_backend_new_v3.git` | Legacy v3 monorepo mirror — rarely used. | +| `frontend-v3` | `https://git.albousalh.com/devops/encoach_frontend_new_v3.git` | Legacy v3 frontend mirror — rarely used. | +| `frontend` | `https://git.albousalh.com/devops/encoach_frontend_new_v2.git` | Legacy v2 frontend monorepo — rarely used. | + +> Rename history: `backend-v4 → origin-backend`, `frontend-v4 → origin-frontend`, +> `origin → mirror-monorepo` (applied 2026-04-19). If you have old scripts or +> notes referencing the old names, update them or add aliases via +> `git remote rename`. + +### 6.3 Feature Development Workflow + +Everything is edited inside this workspace, but a change is only **merged** +once it lands on `main` of the two canonical repos. + +```bash +# 1. Start from a clean workspace synced with the monorepo mirror +git checkout v4 +git pull mirror-monorepo v4 # optional: refresh full-tree mirror + +# 2. Create a feature branch +git checkout -b feature/ + +# 3. Make changes, commit small and often +git add +git commit -m "feat(): " + +# 4. Merge back onto v4 (or open a PR if branch protection is enabled) +git checkout v4 +git merge --no-ff feature/ + +# 5. Publish to the CANONICAL repos (mandatory — see §6.4) +# This is what the team lead / CI will see on the VPS. + +# 6. (Optional) push the monorepo mirror for a full-tree backup / docs +git push mirror-monorepo v4 +``` + +### 6.4 Publishing to the Canonical Repos (MANDATORY) + +The canonical repos expect a **flat tree** (no `backend/` or `frontend/` +prefix). Use `git subtree split` to carve each subdirectory into its own +parallel history, then push to `main`. These commands produce clean +fast-forwards when nothing has been pushed directly to the canonical repos +from elsewhere — which is the invariant we enforce. + +```bash +# ── Publish BACKEND → encoach_backend_v4 ───────────────────────────────── +git subtree split --prefix=backend HEAD -b split-backend-v4 +git push origin-backend split-backend-v4:main +git branch -D split-backend-v4 # cleanup the temp branch + +# ── Publish FRONTEND → encoach_frontend_v4 ─────────────────────────────── +git subtree split --prefix=frontend HEAD -b split-frontend-v4 +git push origin-frontend split-frontend-v4:main +git branch -D split-frontend-v4 +``` + +If a push is rejected as non-fast-forward, fetch and inspect before forcing: + +```bash +git fetch origin-backend origin-frontend +git log --oneline origin-backend/main..split-backend-v4 # what we're adding +git log --oneline split-backend-v4..origin-backend/main # divergence, if any +``` + +Only use `--force` after confirming the remote `main` has no commits worth +keeping (e.g. someone pushed directly and it must be overwritten). Since the +canonical repos are the source of truth, a force-push there is a **destructive +production operation** — pair it with a Slack heads-up to the team lead. + +### 6.5 What Each Canonical Repo Contains + +**`encoach_backend_v4` (branch `main`)** — everything under `backend/` hoisted +to repo root: + +- `custom_addons/` (all EnCoach Odoo addons + `encoach_lms_api`) +- `Dockerfile`, `docker-compose.yml`, `requirements.txt` + +**Not shipped:** `openeducat_erp-19.0/` is vendored locally but not tracked in +git. The VPS needs OpenEduCat installed separately — either baked into the +container image, mounted at `/opt/odoo/extra_addons`, or installed via pip if a +package release is used. `backend/odoo.conf` is also not currently tracked; add +it in a follow-up commit if the VPS container needs a baked-in config. + +**`encoach_frontend_v4` (branch `main`)** — everything under `frontend/` hoisted +to repo root: + +- `src/`, `public/`, `docs/` (frontend docs only) +- `package.json`, `package-lock.json`, `bun.lock`, `bun.lockb` +- `Dockerfile`, `docker-compose.yml` +- `vite.config.ts`, `tsconfig.*.json`, `tailwind.config.ts`, `eslint.config.js` +- `index.html`, `components.json`, `.env.example` + +**Not shipped** (ignored by `frontend/.gitignore`): `node_modules/`, `dist/`, +`.vite/`, `.env`, `.env.development`, `.env.production`. + +### 6.6 VPS Deploy — Team-Lead Quickstart + +```bash +# Backend +git clone https://git.albousalh.com/devops/encoach_backend_v4.git +cd encoach_backend_v4 && docker compose up -d --build + +# Frontend +git clone https://git.albousalh.com/devops/encoach_frontend_v4.git +cd encoach_frontend_v4 && cp .env.example .env # set VITE_API_BASE_URL +docker compose up -d --build # or: npm ci && npm run build +``` + +### 6.7 Safety Rules (DO NOT COMMIT) + +Already excluded via `.gitignore` — do not force them in: + +- `pgdata/`, `pgdata_bak_*/` — PostgreSQL data directories (up to ~700 MB) +- `frontend/node_modules/`, `frontend/dist/`, `frontend/.vite/` — build artifacts +- `.env`, `.env.development`, `.env.production` — local secrets +- `.venv/`, `venv/`, `.conda-envs/` — Python virtualenvs +- `*.zip`, `*.tar.gz` — archives + +`odoo.conf` at the workspace root IS tracked (local Mac dev config, harmless). +`backend/odoo.conf` (container-friendly) is **not** tracked; add it in a +follow-up if the VPS image needs it baked in. + +### 6.8 Git Credentials + +Stored in macOS Keychain for `git.albousalh.com`: +- Username: `yamen` +- Auth: token-based (stored in keychain, not in this file) + +--- + +## 7. API Routes Reference + +### Authentication (`encoach_api`) +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/login` | public | Login, returns JWT | +| GET | `/api/user` | JWT | Get current user | +| POST | `/api/logout` | public | Logout (client clears token) | + +### Exam Generation & AI (`encoach_ai`) +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/exam//generate` | AI-generate content for a module (reading/listening/writing/speaking) | +| POST | `/api/exam/generation/submit` | Submit generated exam, create questions | +| POST | `/api/exam//generate/save` | Save generated content | + +### Exam Session (`encoach_exam_template`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/exam//session` | Get exam session (creates attempt if needed) | +| POST | `/api/exam//autosave` | Autosave student answers | +| POST | `/api/exam//submit` | Submit exam for scoring | +| GET | `/api/exam//status` | Get attempt status | +| GET | `/api/exam//results` | Get exam results | + +### Custom Exams +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/exam/custom/list` | List custom exams | +| POST | `/api/exam/custom/create` | Create custom exam | +| GET/PUT/DELETE | `/api/exam/custom/` | CRUD on custom exam | + +### Exam Structures, Templates, Schedules, Assignments +| Method | Route | Description | +|--------|-------|-------------| +| GET/POST | `/api/exam-structures` | List/create exam structures | +| GET/POST | `/api/exam/templates` | List/create exam templates | +| GET/POST | `/api/exam-schedules` | List/create schedules | +| GET | `/api/student/my-exams` | Student's assigned exams | +| GET/POST | `/api/assignments` | List/create assignments | + +### Rubrics & Approval Workflows +| Method | Route | Description | +|--------|-------|-------------| +| GET/POST | `/api/rubrics` | List/create rubrics | +| GET/POST | `/api/rubric-groups` | List/create rubric groups | +| GET/POST | `/api/approval-workflows` | List/create approval workflows | +| POST | `/api/approval-requests//approve` | Approve request | +| POST | `/api/approval-requests//reject` | Reject request | + +### Entities +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/entities` | List entities | + +### LMS Core (`encoach_lms_api` — `lms_core.py`) +| Method | Route | Description | +|--------|-------|-------------| +| GET/POST | `/api/courses` | List all courses (paginated) / create course | +| GET/PUT/DELETE | `/api/courses/` | Get / update / delete a single course | +| GET/POST | `/api/students` | List students / create student | +| GET/PUT/DELETE | `/api/students/` | Get / update / delete a student | +| GET | `/api/student/my-courses` | Enrolled courses for logged-in student (with progress) | +| POST | `/api/students//enroll` | Enroll a student in one or more courses | +| POST | `/api/courses//enroll` | Bulk-enroll students in a course | +| GET/POST | `/api/teachers` | List / create teachers | +| GET/PUT/DELETE | `/api/teachers/` | Get / update / delete a teacher | +| GET/POST | `/api/batches` | List / create batches | +| GET/PUT/DELETE | `/api/batches/` | Get / update / delete a batch | + +### LMS Courseware (`encoach_lms_api` — `courseware.py`) +| Method | Route | Description | +|--------|-------|-------------| +| GET/POST | `/api/courses//chapters` | List / create chapters for a course | +| GET/PUT/DELETE | `/api/chapters/` | Get / update / delete a chapter | +| POST | `/api/chapters//materials` | Upload material (JSON or FormData) | +| GET/PUT/DELETE | `/api/materials/` | Get / update / delete a material | +| GET | `/api/materials//download` | Download material file | +| GET | `/api/materials//content` | Serve material file inline (for iframe embed, accepts ?token= param) | +| POST | `/api/materials//view` | Mark material as viewed | +| POST | `/api/chapters//progress/complete` | Mark chapter as completed | +| GET | `/api/courses//completion` | Get course completion status (% progress, post-test availability) | +| GET | `/api/materials/search` | Search all materials across courses (for Admin Resources page) | + +### Institutional (`encoach_lms_api`) + +These power the 14 institutional admin pages under `/admin/*` (see §14) and back the write-flow test in §17. Every route is JWT-protected. + +**Academic & Departments** +| Method | Route | Description | +|--------|-------|-------------| +| GET / POST | `/api/academic-years` | List / create academic years | +| GET / PATCH / DELETE | `/api/academic-years/` | Get / update / delete year (DELETE cascades terms) | +| POST | `/api/academic-years//generate-terms` | Auto-create terms spanning the year (`two_sem`, `three_trim`, `four_qtr`) | +| GET / POST | `/api/academic-terms` | List / create academic terms | +| GET / PATCH / DELETE | `/api/academic-terms/` | Term CRUD | +| GET / POST | `/api/departments` | List / create departments | +| GET / PATCH / DELETE | `/api/departments/` | Department CRUD | + +**Admissions** +| Method | Route | Description | +|--------|-------|-------------| +| GET / POST | `/api/admission-registers` | List / create admission registers | +| GET / PATCH / DELETE | `/api/admission-registers/` | Register CRUD (DELETE supported) | +| POST | `/api/admission-registers//confirm` | Confirm register | +| GET / POST | `/api/admissions` | List / create admissions (application_date **must** fall inside register window) | +| GET / PATCH / DELETE | `/api/admissions/` | Admission CRUD (DELETE supported) | + +**Institutional Exams & Marksheets** +| Method | Route | Description | +|--------|-------|-------------| +| GET / POST | `/api/inst-exam-sessions` | List / create exam sessions | +| GET / PATCH / DELETE | `/api/inst-exam-sessions/` | Session CRUD | +| POST | `/api/inst-exam-sessions//schedule` | Schedule session | +| GET / POST | `/api/result-templates` | List / create result templates | +| GET / PATCH / DELETE | `/api/result-templates/` | Template CRUD (DELETE cascades `op.marksheet.register`) | +| POST | `/api/result-templates//generate` (alias: `/generate-marksheets`) | Generate marksheet registers (tries `generate_result` then `action_generate_marksheet`) | +| GET | `/api/marksheets` | List marksheet registers | +| POST | `/api/marksheets//validate` | Validate marksheet | + +**Facilities, Assets, Activities** +| Method | Route | Description | +|--------|-------|-------------| +| GET / POST | `/api/facilities` | List / create facilities | +| GET / PATCH / DELETE | `/api/facilities/` | Facility CRUD | +| GET / POST | `/api/assets` | List / create assets (backed by custom `encoach.asset` model) | +| GET / PATCH / DELETE | `/api/assets/` | Asset CRUD | +| GET / POST / PATCH / DELETE | `/api/activity-types` + `/` | Activity-type CRUD | +| GET / POST / PATCH / DELETE | `/api/activities` + `/` | Activity CRUD | + +**Library, Lessons, Gradebook, Leaves, Fees** +| Method | Route | Description | +|--------|-------|-------------| +| GET / POST | `/api/library/media` | List / create media items (authors split into `author_ids`) | +| GET / PATCH / DELETE | `/api/library/media/` | Media CRUD — detail endpoint returns `author_names` + `author_ids` | +| GET / POST | `/api/lessons` | List / create lessons | +| GET / PATCH / DELETE | `/api/lessons/` | Lesson CRUD — detail endpoint preserves `course_id` / `batch_id` on edits | +| GET / POST / DELETE | `/api/grading-assignments` + `/` | Grading assignment CRUD (auto-resolves `assignment_type` and default `faculty_id`) | +| GET | `/api/gradebooks` + `/api/gradebook-lines?gradebook_id=` | Gradebook drill-down | +| GET | `/api/student-progress` + `/` | Student progress list/detail | +| GET / POST | `/api/student-leaves` | List / create leave requests | +| POST | `/api/student-leaves//approve` | Approve leave | +| DELETE | `/api/student-leaves/` | Delete leave | +| GET / POST | `/api/fees-plans` | List / create fee plans | +| POST | `/api/fees-plans//create-invoice` | Generate accounting invoice (auto-wires income account on the product template for Odoo 19 JSONB property storage if missing) | +| POST | `/api/fees-plans//register-payment` | Register a payment against the invoice | + +### Other +| Route | Description | +|-------|-------------| +| `/api/taxonomy/*` | Subject/topic taxonomy tree | +| `/api/adaptive/*` | Adaptive learning dashboard/signals | +| `/api/placement/*` | Placement test start/answer/results | +| `/api/grading/*` | Grading queue, AI suggest | +| `/api/scores/*` | Score release/reject | +| `/api/course/*` | Course CRUD, gap analysis, auto-generate | +| `/api/ai-course/*` | AI course creation pipelines | +| `/api/auth/register` | User registration | + +--- + +## 8. Key Odoo Models + +| Model | Table | Description | +|-------|-------|-------------| +| `encoach.exam.custom` | `encoach_exam_custom` | Custom exams (16 records) | +| `encoach.exam.custom.section` | `encoach_exam_custom_section` | Exam sections (reading/listening/writing/speaking) | +| `encoach.question` | `encoach_question` | Questions (38 records) | +| `encoach.student.attempt` | `encoach_student_attempt` | Student exam attempts (10 records) | +| `encoach.student.answer` | `encoach_student_answer` | Individual answers | +| `encoach.entity` | `encoach_entity` | Organizations (1: "EnCoach Demo Academy") | +| `encoach.rubric` | `encoach_rubric` | Grading rubrics | +| `encoach.exam.template` | `encoach_exam_template` | Exam templates | +| `encoach.exam.structure` | `encoach_exam_structure` | Exam structures | +| `encoach.exam.schedule` | `encoach_exam_schedule` | Exam schedules | +| `encoach.exam.assignment` | `encoach_exam_assignment` | Exam assignments | +| `encoach.course.chapter` | `encoach_course_chapter` | Course chapters (ordered by sequence) | +| `encoach.chapter.material` | `encoach_chapter_material` | Materials within chapters (pdf, video, article, link) | +| `encoach.chapter.progress` | `encoach_chapter_progress` | Per-student chapter progress tracking | +| `encoach.course.completion` | `encoach_course_completion` | Per-student course completion (% progress, post-test flag) | +| `encoach.resource` | `encoach_resource` | Learning resources linked to taxonomy (subject, domain, topics, objectives) | +| `encoach.resource.tag` | `encoach_resource_tag` | Tags for categorizing resources | +| `encoach.subject` | `encoach_subject` | Taxonomy subjects (e.g. English, Mathematics) | +| `encoach.domain` | `encoach_domain` | Taxonomy domains under subjects (e.g. Grammar, Listening) | +| `encoach.topic` | `encoach_topic` | Taxonomy topics under domains | +| `encoach.learning.objective` | `encoach_learning_objective` | Learning objectives under topics (Bloom levels) | +| `op.course` (extended) | `op_course` | OpenEduCat course + `description`, `max_capacity`, `encoach_subject_id`, `topic_ids`, `learning_objective_ids` | +| `op.student` | `op_student` | OpenEduCat student model | +| `op.faculty` | `op_faculty` | OpenEduCat teacher/faculty model | +| `op.batch` | `op_batch` | OpenEduCat batch model | + +### Institutional models (OpenEduCat + EnCoach) + +| Model | Table | Description | +|-------|-------|-------------| +| `op.academic.year` | `op_academic_year` | Academic year (name unique, `term_structure`) | +| `op.academic.term` | `op_academic_term` | Term under a year; FK to year is `RESTRICT` — delete-year cascades terms first | +| `op.department` | `op_department` | Department | +| `op.admission.register` | `op_admission_register` | Admission register window (`start_date` / `end_date`) | +| `op.admission` | `op_admission` | Admission — constrained so `application_date ∈ [register.start_date, register.end_date]` | +| `op.exam.session` | `op_exam_session` | Institutional exam session | +| `op.result.template` | `op_result_template` | Marksheet result template — `generate_result()` in Odoo 19 (older: `action_generate_marksheet`) produces `op.marksheet.register` rows | +| `op.marksheet.register` | `op_marksheet_register` | Generated marksheet register (lines + validate workflow) | +| `op.facility` | `op_facility` | Physical facility (rooms, labs, etc.) | +| `encoach.asset` | `encoach_asset` | **New lightweight asset model** (replaces missing `op.asset`): `name`, `code`, `facility_id`, `description`, `active` | +| `op.activity.type` / `op.activity` | `op_activity_type` / `op_activity` | Activity categorization + activities | +| `op.media` / `op.author` | `op_media` / `op_author` | Library media + authors (M2M) | +| `encoach.lesson` | `encoach_lesson` | Lesson tied to course + batch | +| `grading.assignment` / `grading.assignment.type` | `grading_assignment*` | Grading assignments — `assignment_type` and `faculty_id` are required | +| `encoach.student.leave` | `encoach_student_leave` | Student leave requests with approve workflow | +| `op.fees.terms` + `.line` | `op_fees_terms*` | Fees terms; `line_ids` must sum to 100% | +| `op.student.fees.details` | `op_student_fees_details` | Per-student fees; `get_invoice()` requires an income account on product template (stored as JSONB in Odoo 19) | + +--- + +## 9. Installed EnCoach Modules + +| Module | Description | +|--------|-------------| +| `encoach_adaptive` | Adaptive learning engine | +| `encoach_ai` | AI generation, search, insights | +| `encoach_ai_course` | AI course creation | +| `encoach_api` | REST API auth + base helpers | +| `encoach_branding` | Entity branding | +| `encoach_core` | Core models (user, entity, role) | +| `encoach_course_gen` | Course generation | +| `encoach_exam_template` | Exam CRUD, sessions, rubrics, assignments | +| `encoach_lms_api` | LMS: courses, students, teachers, batches, chapters, materials, enrollment, progress | +| `encoach_quality_gate` | Quality gate checks | +| `encoach_resources` | Learning resources | +| `encoach_scoring` | Grading and score release | +| `encoach_taxonomy` | Subject/topic taxonomy | +| `encoach_vector` | Vector embeddings | + +--- + +## 10. Exam Lifecycle Flow + +``` +1. Admin → /admin/generation page + ├── Select modules (reading, listening, writing, speaking) + ├── Set parameters (difficulty, timer, grading, rubric, entity, etc.) + └── Click "Generate with AI" → POST /api/exam//generate + +2. AI generates content (or fallback templates if OpenAI not configured) + +3. Admin reviews generated content → Submit + ├── "Submit & skip approval" → POST /api/exam/generation/submit (skip_approval=true) + ├── "Submit for approval" → POST /api/exam/generation/submit (skip_approval=false) + └── Creates: exam_custom + sections + questions in DB + +4. Admin assigns exam → /api/exam/custom//assign or /api/exam-schedules + +5. Student → /student/my-exams → clicks exam → /student/exam/ + ├── GET /api/exam//session → loads sections, questions, passage_text + ├── POST /api/exam//autosave → saves answers periodically + └── POST /api/exam//submit → auto-scores objective questions + +6. Student → /student/exam//results → views score breakdown +``` + +--- + +## 11. Student Learning Lifecycle Flow + +``` +1. Admin/Teacher → /teacher/courses → "New Course" + ├── Create course (title, code, description, max_capacity) + └── Course stored in op.course (extended with description, max_capacity) + +2. Teacher → /teacher/courses/:courseId/chapters → "Add Chapter" + ├── Create chapters with sequence ordering + └── Upload materials (pdf, video, article, link) per chapter + └── POST /api/chapters//materials (JSON or FormData) + +3. Admin → /admin/courses → "Enroll Students" + ├── Bulk-enroll students via POST /api/courses//enroll + └── Or enroll individually via POST /api/students//enroll + +4. Student → /student/dashboard → sees enrolled courses with progress + ├── GET /api/student/my-courses → returns courses + chapter_count + progress % + └── Cards show "Start Learning" or "Continue Learning" + +5. Student → /student/courses/:id → course detail with chapter list + ├── Shows chapter list with material counts and lock status + └── Student clicks a chapter → /student/courses/:courseId/chapters/:chapterId + +6. Student → chapter view → views materials + ├── POST /api/materials//view → marks material as viewed + ├── Progress tracked in encoach.chapter.progress + └── When all materials viewed → chapter auto-completes + +7. Student → clicks "Mark Chapter Complete" + ├── POST /api/chapters//progress/complete + └── Triggers _check_course_completion() + +8. Course completion check + ├── When all chapters completed → encoach.course.completion updated + ├── progress = 100%, completed_at = now + └── post_test_available = True → student can take final exam +``` + +--- + +## 12. Taxonomy ↔ Resource ↔ Course Relationships + +``` +encoach.subject (e.g. English) + └── encoach.domain (e.g. Grammar, Listening, Reading) + └── encoach.topic (e.g. Verb Tenses, Relative Clauses) + └── encoach.learning.objective (Bloom levels: Remember, Understand, Apply...) + +encoach.resource ──M2O──→ encoach.subject (subject_id) + ──M2O──→ encoach.domain (domain_id) + ──M2M──→ encoach.topic (topic_ids) + ──M2M──→ encoach.learning.objective (learning_objective_ids) + +op.course (extended) ──M2O──→ encoach.subject (encoach_subject_id) + ──M2M──→ encoach.topic (topic_ids) + ──M2M──→ encoach.learning.objective (learning_objective_ids) + +encoach.course.chapter ──M2O──→ encoach.subject (encoach_subject_id) + ──M2M──→ encoach.topic (topic_ids) + ──M2M──→ encoach.learning.objective (learning_objective_ids) +``` + +### Frontend: `TaxonomyCascade` Component + +Reusable cascading picker (`frontend/src/components/TaxonomyCascade.tsx`) used in: +- Admin Resource Manager (upload + edit dialogs) +- Teacher Library (upload dialog) + +Provides Subject → Domain → Topics → Learning Objectives selection with auto-fetching and reset-on-parent-change logic. + +### API endpoints for taxonomy data + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/subjects` | List subjects (paginated, returns `items` array) | +| GET | `/api/domains?subject_id=N` | List domains filtered by subject | +| GET | `/api/topics?domain_id=N` | List topics filtered by domain | +| GET | `/api/learning-objectives?topic_id=N` | List objectives filtered by topic | + +--- + +## 13. Known Issues & Notes + +1. **PostgreSQL version**: pgdata is PG 18; must use micromamba `pg_ctl` (`/Users/yamenahmad/micromamba/envs/odoo19/bin/pg_ctl`), NOT conda env PG 17. +2. **`odoo.conf` requires `db_name`**: Without `db_name = encoach_v2`, all API routes return 404. +3. **`npm` not in default PATH**: Must add `/Users/yamenahmad/.local/node/bin` to PATH before running frontend. +4. **OpenAI not configured**: AI generation uses fallback templates (static mock content). Set OpenAI API key in Odoo system parameters to enable real AI. +5. **Gitea deployment hook**: Frontend v3 server has a `docker-compose.yml` conflict that blocks auto-deploy. SSH and stash/checkout on server to fix. +6. **Student passwords were reset**: Student demo users (`sarah`, `omar`, `layla`) now use password **`student123`** (not `admin`). Passwords were reset via `psycopg2` + `passlib` script. To reset again: + ```python + from passlib.context import CryptContext + ctx = CryptContext(schemes=['pbkdf2_sha512']) + new_hash = ctx.hash('student123') + # UPDATE res_users SET password = '' WHERE login = 'sarah@encoach.test'; + ``` +7. **`op.course` extended fields**: The `op.course` model (OpenEduCat) was extended via `encoach_lms_api/models/course_ext.py` to add `description` (Text) and `max_capacity` (Integer). If the module is not installed/updated, course creation will fail with `ValueError: Invalid field 'description'`. +8. **`material_count` computed field**: The `material_count` on `encoach.course.chapter` requires `@api.depends('material_ids')` to recompute when materials are added. If counts show 0 after adding materials, restart Odoo with `-u encoach_lms_api`. +9. **Taxonomy API response format**: Taxonomy list endpoints (`/api/subjects`, `/api/domains`, `/api/topics`) return paginated responses with data under an `items` key (`{"items": [...], "total": N}`), not a `data` key. The frontend `taxonomy.service.ts` handles both formats. +10. **Resource taxonomy fields via FormData**: When uploading resources, `topic_ids` and `learning_objective_ids` are sent as comma-separated strings in multipart FormData. The backend `create_resource` handler parses these into proper Many2many commands `[(6, 0, ids)]`. +11. **Module upgrades for schema changes**: After adding new fields to Odoo models (e.g. `encoach_subject_id` on `op.course`), you must run `./run.sh -u --stop-after-init` to apply DB schema changes before the server can use them. + +--- + +## 14. Frontend Key Pages + +### Admin Pages +| Route | Component | Description | +|-------|-----------|-------------| +| `/admin/dashboard` | AdminLmsDashboard | Platform overview, AI tips, stats (LMS-focused) | +| `/admin/platform` | AdminDashboard | Alternative platform dashboard | +| `/admin/courses` | AdminCourses | Course table with enroll/assign/delete actions | +| `/admin/students` | AdminStudents | Student management | +| `/admin/teachers` | AdminTeachers | Teacher management | +| `/admin/users` | UsersPage | All platform users + roles (Management section) | +| `/admin/entities` | EntitiesPage | Entity/organization management | +| `/admin/classrooms` | ClassroomsPage | Batches/classrooms with student management | +| `/admin/user-roles` | UserRoles | User role assignments (⚠ NOT `/admin/roles`) | +| `/admin/roles-permissions` | RolesPermissions | Roles & permissions matrix | +| `/admin/authority-matrix` | AuthorityMatrix | Authority matrix across roles | +| `/admin/generation` | GenerationPage | AI exam generation | +| `/admin/examsList` | ExamsListPage | List all exams (⚠ NOT `/admin/exams`) | +| `/admin/rubrics` | RubricsPage | Manage rubrics | +| `/admin/assignments` | AssignmentsPage | Manage assignments | +| `/admin/approval-workflows` | ApprovalWorkflowsPage | Approval workflow config | +| `/admin/taxonomy` | TaxonomyManager | Manage subjects, domains, topics, objectives (inline editable) | +| `/admin/resources` | ResourceManager | Upload/edit resources with full taxonomy cascade (Subject→Domain→Topics→Objectives) | + +### Admin — Institutional Pages + +Backed by `encoach_lms_api` + OpenEduCat models. All 14 pages verified CREATE / EDIT / DELETE / WORKFLOW in the write-flow sweep (see §17). + +| Route | Page | Backing API | Key actions | +|-------|------|-------------|-------------| +| `/admin/academic-years` | Academic Years | `/api/academic-years`, `/generate-terms` | CREATE, generate-terms, EDIT, DELETE (cascades terms) | +| `/admin/academic-terms` | Academic Terms | `/api/academic-terms` | CRUD | +| `/admin/departments` | Departments | `/api/departments` | CRUD | +| `/admin/admission-registers` | Admission Registers | `/api/admission-registers` | CREATE, confirm, DELETE | +| `/admin/admissions` | Admissions | `/api/admissions` | CREATE (date-constrained), DELETE | +| `/admin/inst-exam-sessions` | Institutional Exam Sessions | `/api/inst-exam-sessions` | CREATE, schedule, EDIT, DELETE | +| `/admin/marksheets` | Marksheets | `/api/result-templates`, `/api/marksheets` | Create template, generate, validate marksheet, DELETE | +| `/admin/facilities` | Facilities | `/api/facilities`, `/api/assets` | Facility CRUD + nested assets | +| `/admin/activities` | Activities | `/api/activity-types`, `/api/activities` | Type + activity CRUD | +| `/admin/library` | Library | `/api/library/media` | Media + authors, detail exposes author links | +| `/admin/lessons` | Lessons | `/api/lessons` | CRUD; edits preserve `course_id` / `batch_id` | +| `/admin/gradebook` | Gradebook | `/api/grading-assignments`, `/api/gradebooks`, `/api/gradebook-lines` | Assignment CRUD + gradebook drill | +| `/admin/student-progress` | Student Progress | `/api/student-progress` | Drill-through detail | +| `/admin/student-leave` | Student Leave | `/api/student-leaves` | CREATE, approve, DELETE | +| `/admin/fees` | Fees | `/api/fees-plans`, `/create-invoice`, `/register-payment` | List seeded plans, generate invoice, register payment | + +### Teacher Pages +| Route | Component | Description | +|-------|-----------|-------------| +| `/teacher/dashboard` | TeacherDashboard | Teacher overview | +| `/teacher/courses` | TeacherCourses | Course cards with Edit, Chapters & Materials, AI Workbench | +| `/teacher/courses/new` | CourseBuilder | Create new course | +| `/teacher/courses/:courseId/chapters` | CourseChapters | Manage chapters and materials for a course | +| `/teacher/courses/:courseId/chapters/:chapterId` | ChapterDetail | Chapter detail with material list | +| `/teacher/courses/:courseId/workbench` | AiWorkbench | AI content generation workbench | +| `/teacher/assignments` | TeacherAssignments | Manage assignments | +| `/teacher/library` | TeacherLibrary | Upload resources with full taxonomy cascade (Subject→Domain→Topics→Objectives) | + +### Student Pages +| Route | Component | Description | +|-------|-----------|-------------| +| `/student/dashboard` | StudentDashboard | Enrolled courses, progress, grades, quick actions | +| `/student/courses` | StudentCourses | Enrolled courses with progress badges | +| `/student/courses/:id` | StudentCourseDetail | Course chapters, materials, grades | +| `/student/courses/:courseId/chapters/:chapterId` | StudentChapterView | View materials, mark viewed/complete | +| `/student/my-exams` | StudentExamsPage | Student's assigned exams | +| `/student/exam/:id` | ExamSession | Take an exam | +| `/student/exam/:id/results` | ExamResults | View results | +| `/student/assignments` | StudentAssignments | Homework assignments | + +--- + +## 15. Database Connection + +``` +Host: localhost +Port: 5432 +Database: encoach_v2 +User: yamenahmad +Password: (none) +``` + +Quick access: +```bash +PGBIN="/Users/yamenahmad/micromamba/envs/odoo19/bin" +"$PGBIN/psql" -U yamenahmad -d encoach_v2 +``` + +--- + +## 16. QA Test Report — 2026-04-18 + +A full end-to-end browser test was executed across all three user types (admin, teacher, student), driven by an autonomous browser agent. 32 distinct pages were verified plus an exam session flow. + +### 16.1 Backend API Health — 32/33 endpoints healthy (pre-fix) + +All tested with admin JWT unless noted: + +| Category | Endpoints Tested | Status | +|----------|-----------------|--------| +| Auth | `/api/login`, `/api/user` | ✅ | +| Users & Roles | `/api/users/list`, `/api/users/with-roles`, `/api/roles`, `/api/permissions`, `/api/authority-matrix`, `/api/user-authority-matrix` | ✅ | +| Entities | `/api/entities`, `/api/entities//roles` | ✅ | +| LMS Core | `/api/courses`, `/api/students`, `/api/teachers`, `/api/batches`, `/api/batches//students` | ✅ | +| Taxonomy | `/api/subjects`, `/api/domains`, `/api/topics`, `/api/learning-objectives` | ✅ | +| Resources | `/api/resources`, `/api/resource-tags` | ✅ | +| Exams | `/api/exam/custom/list`, `/api/exam/templates`, `/api/exam-structures`, `/api/exam-schedules`, `/api/rubrics`, `/api/rubric-groups` | ✅ | +| Assignments | `/api/assignments` | ✅ | +| Stats | `/api/stats`, `/api/stats/performance`, `/api/analytics/student`, `/api/adaptive/dashboard` | ✅ | +| Courseware | `/api/courses//chapters`, `/api/courses//completion`, `/api/materials/search` | ✅ | +| Grading | `/api/grading/queue` | ✅ | +| Student role | `/api/student/my-courses`, `/api/student/my-exams`, `/api/assignments` | ✅ | +| Approvals | `/api/approval-workflows`, `/api/approval-requests`, `/api/approval-users` | ✅ (after fix) | + +### 16.2 Bugs Fixed During This Pass + +**Bug #1: `/api/approval-workflows` returned 500** +- **Cause**: `encoach_exam_template/controllers/approval_workflows.py` ran raw SQL against tables (`encoach_approval_workflow`, `encoach_approval_request`) that are only created by the `encoach_approval` module — which lives in `new_project/` and is NOT installed in the active `backend/`. +- **Fix**: Added a `_table_exists(cr, name)` helper using `to_regclass()`. Both `list_workflows` and `list_requests` now return `{items: [], total: 0}` gracefully when the table is absent, matching the frontend's empty-state expectation. + +**Bug #2: `DELETE /api/courses/` returned opaque 500 for courses with enrollments** +- **Cause**: `op.course` has a `RESTRICT` FK from `op_student_course`. Postgres raised a raw violation with no user-friendly handling. +- **Fix**: `delete_course` in `encoach_lms_api/controllers/lms_core.py` now (a) detects enrollments first and returns a 409 with a clear message, (b) accepts `?force=true` to cascade-delete enrollments and detach linked batches before removing the course. + +### 16.3 Admin Workflows — All Pages Pass ✅ + +| Page | Route | Result | +|------|-------|--------| +| Dashboard | `/admin/dashboard` | Stats cards, AI tips, platform insights render | +| Users | `/admin/users` | 7 users shown with roles, filter tabs work | +| Entities | `/admin/entities` | Demo Academy entity, 6 users / 5 roles | +| Classrooms | `/admin/classrooms` | IELTS 2026 Spring (3 students), student dialog works | +| User Roles | `/admin/user-roles` | 7 users, 5 available roles, assignments rendered | +| Roles & Permissions | `/admin/roles-permissions` | 5 roles × 115 permissions × 22 categories | +| Authority Matrix | `/admin/authority-matrix` | 257 granted cells, 45 % coverage, CSV export present | +| Courses | `/admin/courses` | 3 courses, enroll-students dialog with BOTH "Individual" and "By Classroom" tabs verified | +| Taxonomy | `/admin/taxonomy` | 3 subjects (English, Math, IT) with full tree | +| Resources | `/admin/resources` | 15 items (6 library + 9 course materials), filters & cascade pickers work | +| Exams List | `/admin/examsList` | 16 custom exams, search filters correctly | +| Generation | `/admin/generation` | Loads; Official/Practice tabs work | +| Rubrics | `/admin/rubrics` | 3 rubrics + groups tab | +| Assignments | `/admin/assignments` | 2 assignments, filter tabs work | +| Approval Workflows | `/admin/approval-workflows` | Empty-state OK, Create dialog opens | + +### 16.4 Teacher Workflows — All Pages Pass ✅ + +Logged in as `khalid@encoach.test` (Dr. Khalid). Tested 11 pages; all work: + +- `/teacher/dashboard` — 3 Active Courses, 55 Total Students, 0 Pending Grading, 82 % Avg Pass Rate +- `/teacher/courses` — 3 course cards with Edit / Chapters / AI Workbench buttons +- `/teacher/students` — 3 students with batch info, attendance, AI risk indicators +- `/teacher/library` — 15 resources, upload dialog's taxonomy cascade confirmed working +- `/teacher/assignments` — empty state handled gracefully +- `/teacher/courses/3/chapters` — empty state + "Add Chapter" dialog opens with Name/Description/Start Date/Unlock Mode +- `/teacher/attendance` — batch selector + trend chart + AI warning banners +- `/teacher/timetable` — weekly grid 08:00–17:00 +- `/teacher/discussions` / `/teacher/announcements` / `/teacher/profile` — all load cleanly + +### 16.5 Student Workflows — All Pages Pass ✅ + +Logged in as `sarah@encoach.test` (password `student123`). Tested 16 pages + exam session; all work: + +- `/student/dashboard` — 67 % overall progress, 3 courses, 4 upcoming exams popup +- `/student/courses` — 3 cards with progress (100 %, 100 %, 0 %) +- `/student/courses/1` — course detail with "Chapters (1) / Grades (0) / About" tabs + "Take Post-Test" +- `/student/courses/1/chapters/1` — 3/3 materials completed (IELTS Listening video, Reading article, Grammar docs) +- `/student/assignments` / `/student/grades` / `/student/attendance` / `/student/timetable` — all load with graceful empty or populated states +- `/student/profile` — editable name, email, password change +- `/student/discussions` / `/student/announcements` / `/student/messages` / `/student/journey` — empty states clean +- `/student/subjects` — 3 subjects with "Start Diagnostic" CTAs +- `/student/subject-registration` — registration table renders + +**Exam flow** — `/student/exam/2/session` loaded "IELTS Academic Mock - Spring 2026 Full Test" with 170-minute timer, Listening section, 7 questions, MCQ with 4 radio options, Previous / Flag / Next navigation. Clean and functional. + +### 16.6 Demo Data State (Post-Cleanup) + +| Entity | Count | Notes | +|--------|-------|-------| +| Users | 7 | 2 admins, 2 teachers, 3 students | +| Courses | 3 | IELTS Preparation B2, Python Programming Fundamentals, IELTS Listening & Speaking Intensive (junk "tttttt" removed, "test" renamed) | +| Batches | 1 | IELTS 2026 Spring (3 students) | +| Exams (custom) | 16 | published + draft mix | +| Resources | 6 | library resources; +9 course materials | +| Taxonomy subjects | 3 | English (6d/37t), Mathematics (6d/15t), IT (6d/17t) — junk "math" subject removed | + +### 16.7 Route Naming Gotchas + +Two frontend routes differ from the pattern a developer might guess: + +| Expected | Actual | +|----------|--------| +| `/admin/exams` | `/admin/examsList` | +| `/admin/roles` | `/admin/user-roles` | + +The `Admin Pages` table in §14 has been updated with the full correct route list. + +### 16.8 Minor Observations (Non-Blocking) + +- React Router v7 future flag warnings in console on every page (`v7_startTransition`, `v7_relativeSplatPath`) — cosmetic only, ignore until v7 migration. +- forwardRef warning in Badge component — dev-only, not a runtime error. +- `/api/scores` by itself returns 404, but its sub-endpoints (`/api/scores/release` etc.) are valid. +- Session occasionally expired during long admin test runs, requiring re-login. Token TTL may warrant increasing for QA flows. + +--- + +## 17. Institutional Admin Pages — Write-Flow QA — 2026-04-18 + +Comprehensive API-level CREATE / EDIT / DELETE / WORKFLOW sweep across all 14 institutional admin pages (see §14 "Admin — Institutional Pages"). Driven by `test_write_flows.py` against real Odoo data seeded by `seed_institutional.py`. + +**Final result: `SUMMARY PASS=44 FAIL=0 TOTAL=44`** ✅ + +### 17.1 Scenarios covered + +| Page | Actions verified | +|------|------------------| +| Academic Years | CREATE, generate-terms (3), EDIT, DELETE (cascades terms) | +| Academic Terms | EDIT, DELETE | +| Departments | CREATE, EDIT, DELETE | +| Admission Registers | CREATE, confirm, DELETE | +| Admissions | CREATE (date-constrained to register window), DELETE | +| Inst Exam Sessions | CREATE, schedule, EDIT, DELETE | +| Marksheets | CREATE template, generate-marksheets, validate marksheet, DELETE template (cascades registers) | +| Facilities | CREATE, EDIT, DELETE | +| Assets | CREATE, DELETE (backed by new `encoach.asset` model) | +| Activities | CREATE type, CREATE activity, DELETE | +| Library | CREATE media + authors, VERIFY author link, DELETE | +| Lessons | CREATE, EDIT (preserves `course_id` + `batch_id`), DELETE | +| Gradebook | CREATE grading-assignment, DELETE, drill lines | +| Student Progress | DRILL detail | +| Student Leaves | CREATE, approve, DELETE | +| Fees | LIST seeded, create-invoice ✅ | + +### 17.2 Backend fixes applied + +**1. `encoach.asset` — new lightweight model** +`op.asset` does not exist in the installed OpenEduCat set. Added `encoach_lms_api/models/asset.py` with `name`, `code`, `facility_id`, `description`, `active`; registered in `__init__.py`; CRUD access granted via `security/ir.model.access.csv`. `facilities.py` now uses `_asset_model()` → `env['encoach.asset']`. + +**2. Missing DELETE routes added** +- `DELETE /api/admission-registers/` +- `DELETE /api/admissions/` +- `DELETE /api/result-templates/` (cascades `op.marksheet.register` rows created by `generate_result`) +- `DELETE /api/student-leaves/` +- `DELETE /api/academic-years/` now pre-unlinks `academic_term_ids` to avoid the `op_academic_term.academic_year_id` FK RESTRICT violation. + +**3. Missing detail (GET-one) endpoints added** +- `GET /api/lessons/` — exposes full lesson incl. `course_id`, `batch_id` so EDIT can verify those are preserved. +- `GET /api/library/media/` — returns `author_ids` + `author_names` (the list endpoint only returns a comma-separated `author` string). + +**4. Admissions CREATE — defaults + validation** +`create_admission` now defaults `application_date = fields.Date.today()`, builds `name` from `first_name + last_name`, and accepts `nationality_id` or `nationality`. The Odoo constraint `@api.constrains(...)` requires `application_date ∈ [register.start_date, register.end_date]` — the test payload uses `2028-06-15` to sit inside the seeded register window. Added companion `PATCH/PUT /api/admissions/` for edits. + +**5. Grading assignments — required fields resolved** +`create_grading_assignment` no longer sends `user_id` (invalid on `grading.assignment` in this OpenEduCat version). Auto-resolves the required fields: +- `assignment_type`: find-or-create `grading.assignment.type` with code `DEFAULT` +- `faculty_id`: first available `op.faculty` +- `issued_date`: defaults to `fields.Datetime.now()` + +**6. Marksheets generate — Odoo 19 compatibility** +Route aliased as `/api/result-templates//generate` **and** `/generate-marksheets`. Controller tries `rec.generate_result()` (OpenEduCat 19) then falls back to `rec.action_generate_marksheet()` (older versions). + +**7. Fees create-invoice — Odoo 19 JSONB properties** +OpenEduCat's `op.student.fees.details.get_invoice()` raised `UserError: There is no income account defined for this product`. Root cause: **Odoo 19 removed `ir_property` and stores company-dependent fields (like `property_account_income_id`) as JSONB columns on the model itself** — e.g. `product_template.property_account_income_id` is now `jsonb` keyed by `company_id`. + +Fix: `create_invoice` auto-wires a default income account before invoking `get_invoice()`: + +```241:260:backend/custom_addons/encoach_lms_api/controllers/fees.py + if not (fp or {}).get('income'): + AA = request.env['account.account'].sudo() + # Odoo 19: account.account has a M2M `company_ids`. + try: + income_acc = AA.search([ + ('account_type', '=', 'income'), + ('company_ids', 'in', request.env.company.id), + ], limit=1) + except Exception: + income_acc = AA.browse() + if not income_acc: + income_acc = AA.search([('account_type', '=', 'income')], limit=1) + if income_acc: + try: + prod_tmpl.with_company(request.env.company).write({ + 'property_account_income_id': income_acc.id, + }) + except Exception: + _logger.exception('auto-wire income account') +``` + +The `seed_institutional.py` fee-product block now performs the same wiring at seed time, so fresh databases don't hit this error. + +### 17.3 Seeding + +`seed_institutional.py` populates the full institutional fixture set: + +- 1 academic year + auto-generated terms +- 1 department +- 1 admission register + 1 admission +- 1 exam session + 1 result template → marksheets +- Facilities, activities, library media, lessons, gradebooks, student leaves +- **Fees**: 3 `op.fees.terms` (Full, 2-inst 50/50, 3-inst 34/33/33 summing to 100%), 1 `product.product` (`IELTS Preparation B2 Fees`) with income account wired, 3 `op.student.fees.details` records + +Run: + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19 +./run.sh --shell <<< "exec(open('seed_institutional.py').read())" +``` + +### 17.4 Running the write-flow test + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19 +python3 test_write_flows.py +``` + +The script is **idempotent** — it uses timestamped names for academic-year/register payloads so repeated runs don't collide on Odoo's unique constraints. Expected output: `SUMMARY PASS=44 FAIL=0 TOTAL=44`. + +### 17.5 Operational notes for Odoo 19 institutional data + +- `product_template.property_account_income_id` is JSONB keyed by `company_id` — write to it with `.with_company(company).write({...})`, **not** via the removed `ir_property` table. +- `op.admission` has a constraint that `application_date` must fall inside the parent `op.admission.register`'s `[start_date, end_date]` — front-end forms should default to `register.start_date` to avoid validation errors. +- `op.result.template.generate_result()` creates `op.marksheet.register` rows; deleting a template without first unlinking those rows will fail. The DELETE endpoint handles this internally. +- `op.academic.year` → `op.academic.term` FK is `RESTRICT`; always unlink terms before the year. +- `op.fees.terms.line_ids` must sum to 100% (percentages). Seeded terms conform. + + +## §18 Support Admin Pages — Backend + Write-Flow QA — 2026-04-18 + +### 18.1 Scope + +The **Support** group in the admin sidebar is 3 pages: + +| Admin URL | Component | Before | After (this session) | +|-------------------------------|---------------------------|------------------------------------------------|----------------------| +| `/admin/tickets` | `TicketsPage` | Called `/api/tickets` which **did not exist** (404 HTML). | Full backend — `encoach.ticket` model + `/api/tickets` CRUD + `assignedToUser`. | +| `/admin/payment-record` | `PaymentRecordPage` | Hard-coded mock arrays. | Live data from `op.student.fees.details` + `account.move` + stubbed `/api/paymob-orders`. | +| `/admin/settings-platform` | `SettingsPage` | Hard-coded mock arrays. | Codes tab → `encoach.code`, Packages tab → `ir.config_parameter`, Grading tab → `ir.config_parameter`. | + +### 18.2 New backend artefacts + +**Model** — `backend/custom_addons/encoach_lms_api/models/ticket.py`: + +```python +class EncoachTicket(models.Model): + _name = 'encoach.ticket' + _inherit = ['mail.thread'] + subject, description, type, status, source, page_context, + reporter_id, assignee_id, entity_id, resolved_at +``` + +Registered in `models/__init__.py` and `security/ir.model.access.csv` (full CRUD for `base.group_user`). + +**Controllers** — added three new files, all registered in `controllers/__init__.py`: + +| File | Routes | +|-------------------------------------|--------| +| `controllers/tickets.py` | `GET/POST /api/tickets`, `GET/PATCH/DELETE /api/tickets/`, `GET /api/tickets/assignedToUser` | +| `controllers/payments.py` | `GET /api/payment-records` (derived from `op.student.fees.details`), `GET /api/payment-records/invoices` (from `account.move` out_invoice), `GET /api/paymob-orders` (stub, returns `{data:[], message:"Paymob integration not configured."}`) | +| `controllers/platform_settings.py` | `GET/POST/DELETE /api/codes`, `POST /api/codes/generate`, `GET/POST/PATCH/DELETE /api/packages`, `GET/PATCH /api/grading-config` | + +Packages and grading config are persisted in `ir.config_parameter` under the keys `encoach.platform.packages` and `encoach.platform.grading` — no new model table needed. + +### 18.3 Frontend changes + +| File | Change | +|--------------------------------------------------|--------| +| `frontend/src/services/payments.service.ts` | **NEW** — typed client for `/api/payment-records` + `/api/paymob-orders`. | +| `frontend/src/services/platformSettings.service.ts` | **NEW** — typed client for codes, packages, grading-config. | +| `frontend/src/pages/PaymentRecordPage.tsx` | Rewritten: 4 KPI cards + live Payments table + Paymob empty-state + CSV export. Removed all mock arrays. | +| `frontend/src/pages/SettingsPage.tsx` | Rewritten: Codes tab with real Generate Single / Generate Batch and delete; Packages tab with Add Package dialog + delete; Grading tab with Save + client/server validation. | +| `frontend/src/pages/TicketsPage.tsx` | Small fix: accept both `data.items` and `data.data` from the pagination envelope. | + +### 18.4 Seeding + +`seed_institutional.py` now also seeds: + +- **4 tickets** covering all 4 statuses (`open`, `in_progress`, `resolved`) and all main types (`bug`, `feature`, `support`, `feedback`), with realistic subjects / descriptions / `page_context`. +- **4 registration codes** (2 individual student, 2 corporate batch) — one already marked as used to exercise the badge variant. + +### 18.5 Write-flow test — 29/29 PASS + +`test_support_flows.py` exercises: + +| Page | Actions verified | +|----------------------|------------------| +| Tickets | LIST seeded, filter by status + type, text search, CREATE, GET detail, EDIT → in_progress, EDIT → resolved (auto-stamps `resolved_at`), ASSIGN, `assignedToUser` listing, DELETE | +| Codes (Settings) | LIST seeded, generate individual ×3, filter `used=false`, DELETE, generate corporate ×2 | +| Packages (Settings) | LIST defaults, CREATE, EDIT price+discount, DELETE, persistence roundtrip | +| Grading (Settings) | GET, set new scale, server-side validation (rejects `min >= max`), restore IELTS default | +| Payment Record | LIST + totals, filter `paid=false`, invoices list (`account.move`), Paymob stub returns empty | + +Run with: + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19 +python3 test_support_flows.py +``` + +Latest output: `SUMMARY PASS=29 FAIL=0 TOTAL=29`. + +### 18.6 Browser verification + +All 3 pages were opened in a real browser (localhost:8080) after login as `admin` / `admin` and visually verified: + +- **Tickets** — 4 seeded rows visible, reporter = "Administrator", Create Ticket dialog submits and a new row appears immediately. +- **Payment Record** — 4 KPI cards (`Total records`, `Paid`, `Unpaid`, `Total amount`), 3 payment rows for the seeded students, Paymob tab shows the empty-state message. +- **Settings** — Codes tab lists all seeded + generated codes with `Generate Single` working and a success toast; Packages tab shows the 3 default cards; Grading tab shows 0..9 step 0.5 and `Save` returns a success toast. + +No critical console errors — only React Router v7 future-flag warnings. + +### 18.7 Operational notes + +- The `encoach.ticket` model inherits `mail.thread` so OCA-style change tracking works out of the box. Adding followers / comments will work without further wiring. +- The `/api/payment-records` endpoint is **derived** (not stored) — it reflects whatever is in `op.student.fees.details`. Creating an invoice from the Fees page will flip the `paid` badge automatically on the Payment Record page once the invoice is paid. +- `/api/paymob-orders` is a deliberate empty stub. When real Paymob integration is wired, replace the stub body in `controllers/payments.py::list_paymob_orders` — no UI change required; the Payment Record page will light up automatically. +- Packages and grading config live in `ir.config_parameter`. To reset to defaults, delete the two parameters `encoach.platform.packages` and `encoach.platform.grading`. + + +## §19 Training Admin Pages — Backend + Write-Flow QA — 2026-04-18 + +### 19.1 Scope + +The **Training** group in the admin sidebar is 2 pages: + +| Admin URL | Component | Before | After (this session) | +|---------------------------------|--------------------|------------------------------------------|----------------------| +| `/admin/training/vocabulary` | `VocabularyPage` | Hard-coded `vocabItems` array (no API). | Full backend: CRUD + search + level filter + per-user progress tracking. | +| `/admin/training/grammar` | `GrammarPage` | Hard-coded `grammarItems` array (no API).| Full backend: CRUD + search + level filter + per-user progress tracking. | + +### 19.2 New backend artefacts + +**Models** — `backend/custom_addons/encoach_lms_api/models/training.py`: + +| Model | Key fields | +|--------------------------------|------------| +| `encoach.vocab.item` | `word` (unique), `meaning`, `example_sentence`, `level` (CEFR A1–C2), `part_of_speech`, `category`, `active`, computed `learners_count` + `completion_count` | +| `encoach.vocab.progress` | `user_id`, `vocab_id`, `completed`, `mastery` (learning/familiar/mastered), `last_reviewed`, `review_count` — unique per (user, vocab) | +| `encoach.grammar.rule` | `name` (unique), `description`, `example`, `level`, `category`, `active`, computed stats | +| `encoach.grammar.progress` | `user_id`, `rule_id`, `completed`, `last_reviewed`, `review_count` — unique per (user, rule) | + +All four models are registered in `models/__init__.py` and `security/ir.model.access.csv` (full CRUD for `base.group_user`). Uniqueness is enforced via `@api.constrains` instead of `_sql_constraints` — Odoo 19 deprecated the latter (see operational notes below). + +**Controller** — `backend/custom_addons/encoach_lms_api/controllers/training.py`: + +| Method / Path | Verb | Purpose | +|------------------------------------------------------------|----------|---------| +| `/api/training/vocabulary` | `GET` | List with filters: `level`, `category`, `active`, `search`, pagination. Returns `items`, `total`, and a `summary` block (`total`, `completed`, `remaining`, `completion_rate`) relative to the authenticated user. | +| `/api/training/vocabulary` | `POST` | Create (`word` + `meaning` required, unique `word`). | +| `/api/training/vocabulary/` | `GET` | Fetch a single item with the caller's progress merged in. | +| `/api/training/vocabulary/` | `PATCH` | Update any mutable field. | +| `/api/training/vocabulary/` | `DELETE` | Delete (cascades progress). | +| `/api/training/vocabulary//progress` | `POST` | Mark completed / set `mastery` for the authenticated user (creates the progress row if absent, increments `review_count`). | +| `/api/training/grammar` (+ same suffixes) | — | Analogous five endpoints for grammar rules plus `/progress`. | + +`ValidationError` and `UserError` raised from the model layer now surface as **HTTP 400** (not 500), so the frontend can show user-friendly toasts. + +### 19.3 Frontend changes + +| File | Change | +|--------------------------------------------------|--------| +| `frontend/src/services/training.service.ts` | **NEW** — typed client for both vocabulary and grammar (list, CRUD, progress). | +| `frontend/src/pages/VocabularyPage.tsx` | Rewritten. Now: progress summary card with live % complete, search + level filter, checkable list rows, Add Word dialog with CEFR + part-of-speech, delete button per row. AI panel text is dynamic based on `summary.remaining`. | +| `frontend/src/pages/GrammarPage.tsx` | Rewritten. Same pattern as vocabulary but tailored to grammar (rule name, description, example, category). | + +### 19.4 Seeding + +`seed_institutional.py` now seeds: + +- **10 vocabulary items** spanning B1–C1, with realistic meanings and example sentences (Ubiquitous, Pragmatic, Eloquent, Meticulous, Ambiguous, Coherent, Versatile, Concise, Collaborate, Rationale). +- **8 grammar rules** spanning A2–C1 (Conditionals Type 2/3, Passive Voice, Relative Clauses, Subject-Verb Agreement, Modal Speculation, Reported Speech, Articles, Present Perfect vs Past Simple). +- **6 progress rows** for the admin user (3 vocab mastered, 3 grammar completed) so the UI always shows non-zero state on a freshly seeded DB. + +Run with: + +```bash +.conda-envs/odoo19/bin/python odoo/odoo-bin shell -c odoo.conf --no-http <<'EOF' +exec(open('seed_institutional.py').read()) +EOF +``` + +The seeding block is **idempotent** — re-running it never creates duplicates (each row checks by unique key first). + +### 19.5 Write-flow test — 26/26 PASS + +`test_training_flows.py` exercises: + +| Page | Actions verified | +|-------------------|------------------| +| Vocabulary | LIST seeded, filter by `level=B2`, search `eloqu`, CREATE, **duplicate-word rejection**, **missing-meaning rejection**, GET detail, EDIT level+meaning, PROGRESS mark completed, **summary recomputation** after completion, PROGRESS un-mark, DELETE, GET-after-DELETE → 404 | +| Grammar | LIST seeded, filter by `level=B1`, search `passive`, CREATE, missing-description rejection, GET detail, EDIT level+category, PROGRESS mark / un-mark, DELETE, GET-after-DELETE → 404 | +| Pagination envelope | `size=3 page=1` (vocab), `size=3 page=2` (grammar) — confirms `page` + `size` are echoed in the response | + +Run with: + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19 +python3 test_training_flows.py +``` + +Latest output: `SUMMARY PASS=26 FAIL=0 TOTAL=26`. + +### 19.6 Browser verification + +Both admin pages were opened in a real browser (localhost:8080) after login as `admin` / `admin`: + +- **Vocabulary** — progress card shows live `X/N completed (Y%)` with a progress bar; clicking the circle next to a row marks it complete and the summary instantly recomputes; search `"elo"` narrows the list to just *Eloquent*; the *Add Word* dialog creates a new row + shows a toast. +- **Grammar** — all 8 seeded rules visible (A2–C1), progress tracking works identically; *Add Rule* dialog creates a new row. + +No functional errors in the console — only React Router v7 future-flag warnings. + +### 19.7 Operational notes for Odoo 19 training data + +- **`_sql_constraints` is deprecated in Odoo 19** (the registry emits `Model attribute '_sql_constraints' is no longer supported`). Uniqueness and range checks in these models are therefore implemented with `@api.constrains`, which continues to work on both Odoo 18 and 19. +- Because both progress models have `ondelete='cascade'` back to the library item, deleting a vocabulary/grammar row automatically cleans up every learner's progress row — no orphan rows. +- Progress rows are keyed to `res.users.id`, not `op.student`. Any authenticated API user (admin, teacher, student) sees only their own completions in the `summary` block, while `learners_count` / `completion_count` on the library item itself aggregate across all users. +- The `/api/training///progress` endpoint is **upsert**-like: the first call creates the progress row, subsequent calls update it and bump `review_count`. This lets the frontend treat it as "toggle" without worrying about a missing row. + +## 20. Reports Section (2026-04-19) + +Before this pass, the three pages under the sidebar "Reports" group were **pure mock UIs** — hardcoded JavaScript arrays in each component, non-functional filters, no network requests. They now aggregate real data from the attempt pipeline. + +### 20.1 Scope + +| Page | URL | Before | After | +|------|-----|--------|-------| +| Student Performance | `/admin/student-performance` | 6 hardcoded students ("Sarah Johnson", "Ahmed Hassan"…) | Per-student band averages from `encoach.student.attempt`, CEFR level derived from the latest reportable attempt, entity + level + search filters, CSV export, three KPI cards | +| Stats Corporate | `/admin/stats-corporate` | Hardcoded bar/line/pie data | Four tabs (Overview, Trends, Distribution, Comparison) driven by a single `/api/reports/stats-corporate` rollup — bar chart of module averages (× 10), six-month trend, CEFR distribution pie, and entity comparison bar. Threshold + entity + months filters all hit the backend. | +| Record | `/admin/record` | Four hardcoded rows | Paginated per-user attempt history with search / entity / user / period filters, real exam codes (`EX-###`), duration derived from `started_at` → `completed_at`, CSV export | + +All three pages share a single `/api/reports/filters` endpoint that returns the entities and students that actually have attempts, so dropdowns never offer filters that would produce empty results. + +### 20.2 Backend artifacts + +**New controller** — `backend/custom_addons/encoach_lms_api/controllers/reports.py`: + +| Route | Method | Purpose | +|-------|--------|---------| +| `/api/reports/student-performance` | GET | One row per student. Aggregates per-skill bands across all their reportable attempts, takes the CEFR level from the most recent attempt (or derives it from overall band). Filters: `entity_id`, `level`, `search`, `since`. | +| `/api/reports/stats-corporate` | GET | Corporate rollups: `by_module` (4 module averages × 10 for the bar chart), `trend` (last N months, default 6), `distribution` (CEFR bins with palette colors), `comparison` (per-entity average × 10). Filters: `entity_id`, `threshold` (0-100), `months`, `since`. | +| `/api/reports/record` | GET | Paginated attempt history. Same shape as `{items, total, page, size}`. Filters: `user_id`, `entity_id`, `period=day\|week\|month`, `status`, `page`, `size`. Emits a normalized `status_label` and an `EX-###` exam code. | +| `/api/reports/filters` | GET | Lightweight picker payload: `{entities: [...], students: [...]}` — only students that have at least one attempt, only entities that exist. | + +An attempt is considered **reportable** if `status ∈ {completed, scoring, scored, released}`. In-progress attempts are skipped from aggregates so a student with only a half-finished exam doesn't show up with zeros. + +CEFR labels are normalized to canonical display casing (`Pre-A1 / A1 / … / C2`) via `_normalize_cefr`, and falls back to band-based bucketing via `_cefr_for_band` when `cefr_level` is null on the attempt. + +No new models were needed — the Reports section only reads from: +- `encoach.student.attempt` (overall / per-skill bands, cefr_level, entity_id, started/completed timestamps) +- `encoach.exam.custom` (exam title — note: this model uses `title`, not `name`, which tripped the first version of the controller) +- `encoach.exam.assignment` (optional, to link an attempt back to its assignment when one exists) +- `encoach.entity`, `res.users` + +### 20.3 Frontend artifacts + +**New service** — `frontend/src/services/reports.service.ts`: + +```typescript +reportsService.studentPerformance({ entity_id, level, search, since }) +reportsService.statsCorporate({ entity_id, threshold, months, since }) +reportsService.record({ user_id, entity_id, period, status, page, size }) +reportsService.filters() +``` + +Typed interfaces: `StudentPerformanceRow`, `StatsCorporateResponse` (with nested `StatsModuleRow` / `StatsTrendRow` / `StatsDistributionRow` / `StatsComparisonRow`), `RecordRow`, `ReportsFiltersResponse`. + +**Rewritten pages:** +- `frontend/src/pages/StudentPerformancePage.tsx` — dropped all hardcoded arrays; added three KPI cards (students tracked, avg overall, top performer level), a search input, Entity select populated from `/api/reports/filters`, Level select (`all / A1 / A2 / B1 / B2 / C1 / C2`), CSV export, loading + empty states. Per-row AI Grade Explainer retained. +- `frontend/src/pages/StatsCorporatePage.tsx` — dropped all hardcoded chart data; wired every tab to the `stats-corporate` payload. Added loading + empty states. Threshold buttons (0/50/70/90%) now hit the backend. Comparison tab now renders a real entity bar chart instead of a placeholder panel. AI narrative bubbles receive live data. +- `frontend/src/pages/RecordPage.tsx` — dropped all hardcoded records; wired filters (entity, user, period) + CSV export. Status badge variant adapts to `completed / released / scored / in_progress / scoring`. + +### 20.4 Seeding — `seed_reports.py` (idempotent) + +The live database had only 12 attempts, most of them in-progress with no band scores, so the Reports pages would have shown empty charts. The new script: + +1. Ensures three entities exist: `Acme Corp (ACME)`, `Global Ltd (GLOBAL)`, `Tech Co (TECHCO)` — using `code` (required NOT NULL on `encoach.entity`) as the idempotency key. +2. Walks every existing `in_progress` attempt and assigns it a plausible score matrix from `BAND_MATRIX`, marks it `completed`, stamps `completed_at = started_at + 120 min`, and parks it in one of the three entities if it had none. +3. Creates up to `6 months × 4 students = 24` additional historical attempts (with a random 30% skip to keep the trend line jagged), using a small monthly `delta` so the trend chart rises over time. Each new row is guarded by a 6-day proximity lookup so re-running only fills gaps. +4. Commits once at the end and prints the final reportable-attempt count. + +Result after the first run: **28 reportable attempts** spread across 3 entities, 4 students, and 6 months — enough to populate every chart. + +Run with: + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo +../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf --no-http --stop-after-init < ../seed_reports.py +``` + +### 20.5 Smoke test — 25/25 PASS + +`test_reports_flows.py` exercises all four endpoints: + +| Endpoint | Checks | +|----------|--------| +| `/api/reports/filters` | GET returns `{entities: [...], students: [...]}` with at least one of each | +| `/api/reports/student-performance` | LIST returns rows, row shape complete, `overall` numeric, CEFR level is a known label, `search` filter narrows to the matching student, `level` filter returns only that level, `entity_id` filter narrows rows | +| `/api/reports/stats-corporate` | Envelope has `by_module` + `trend` + `distribution` + `comparison` + `meta`; by_module has the 4 IELTS modules; trend length == `months` (default 6, also tested with 3); distribution has per-level color; `threshold=70` drops attempts considered (28 → 14); entity filter narrows comparison; months=3 shortens trend | +| `/api/reports/record` | Paginated list, row shape complete, `exam_code` is `EX-###`, `status_label` is titlecased, `user_id` filter scopes to that student only, `entity_id` filter scopes to that entity, `period=month` returns the in-window subset, pagination `size=5 page=1` + `page=2` both work | + +Latest output: `Summary: 25 passed, 0 failed, 25 total`. + +Regressions also re-run on this pass: Configuration `24/24`, Support `29/29`, Training `26/26` — all still green. + +### 20.6 Browser verification + +Logged in as `admin / admin` on `localhost:8080` and walked all three pages: + +- **Student Performance** — KPI cards populated (5 students tracked, 6.3 average band), table shows real students (Sarah Ahmed, Omar Khan, Layla Nasser, TestUser WriteFlow, Admin User), Level `A2` filter narrowed to 2 rows, Entity dropdown showed real entities, CSV export triggered a download. No console errors. +- **Stats Corporate** — Heading confirmed `(28 scored attempts)`. Overview bar chart shows 4 modules with values ~64–72. Trends line chart has 6 monthly points Oct–Apr peaking at ~85. Distribution pie: C1 11 / B2 6 / B1 5 / A2 4. Comparison bar: Global Ltd ~81, Acme Corp ~63, Tech Co ~62. AI Summary bubbles quote the real numbers. All filters responsive. +- **Record** — Heading `(28 attempts)`, table shows real student names / exam codes (EX-001, EX-005, EX-017…) / dates / scores / statuses. User filter scoped the list to that student; CSV export worked. + +Network tab showed `/api/reports/filters 200`, `/api/reports/record?size=100 200`, `/api/reports/student-performance 200`, `/api/reports/stats-corporate 200` — zero 4xx/5xx. + +### 20.7 Gotchas fixed during this pass + +- **`encoach.exam.custom` uses `title`, not `name`.** The first version of the Record endpoint threw `AttributeError: 'encoach.exam.custom' object has no attribute 'name'`. The controller now uses `getattr(exam, 'title', None) or getattr(exam, 'display_name', '')` so it survives an upstream rename. +- **`encoach.entity` has a `NOT NULL` `code` column.** `seed_reports.py` originally tried to create entities with just `{'name': ...}` and hit a `NotNullViolation`. The seeder now passes `(name, code)` and also looks up by `code` when probing for existing rows. +- **Both `encoach_exam_template` and `encoach_scoring` declare `encoach.student.attempt`.** The Reports controller only reads fields that exist on both definitions (`listening/reading/writing/speaking/overall_band`, `cefr_level`, `status`, `started_at`, and whichever of `completed_at` / `finished_at` is present — `_attempt_completed_at()` probes both). No field access assumes a specific addon version. +- **In-progress attempts are excluded from the aggregates.** If you ever notice a student you expect to see missing from Student Performance or Stats Corporate, check their attempt status — `in_progress` attempts are deliberately skipped. They DO still appear in the Record page (which shows everything) unless you also pass `status` or `period` filters. + + +--- + +## §21 — Hardening Release (Phase 2/3 completion) + +This section records the final wave of work that closed out all remaining items in the Phase 2 (performance & reliability) and Phase 3 (core AI features, accessibility, onboarding) roadmaps. After this release the platform is considered deployment-ready. + +### 21.1 What shipped + +The release is organised by capability, not file path. + +#### Human-in-the-loop AI quality loop (P3.3 → P3.5) + +| Stage | Where | What it does | +|---|---|---| +| 1. Quality gate | `encoach_exam_template.services.quality_checker` + `ielts_validator` | Runs after every AI exam submit; if scores fall below thresholds the exam is parked in `pending_review` instead of `published`. | +| 2. Review queue | `encoach_exam_template/controllers/review_workflow.py` + `frontend/src/pages/admin/ExamReview{Queue,Detail}.tsx` | Admin-only pages list pending exams, surface aggregate + per-question quality reports, and let a reviewer approve (`→ published`) or reject (`→ draft`) with audit notes. Writes to new `reviewed_by_id`, `reviewed_at`, `review_notes` fields. | +| 3. Prompt library | `encoach_ai.models.ai_prompt` + `controllers/prompt_controller.py` + `frontend/src/pages/admin/AIPromptEditor.tsx` | `encoach.ai.prompt` with a unique `(key, version)` key, one active row per key. Authors can add a new version, activate it (auto-deactivates prior), and dry-run render with sample variables. Renderer uses `str.format_map` against a safe dict. | +| 4. Student feedback | `encoach_ai.models.ai_feedback` + `controllers/feedback_controller.py` + `frontend/src/components/AIFeedbackButtons.tsx` + `frontend/src/pages/admin/AIFeedbackTriage.tsx` | Thumbs up/down on any AI artefact (`question`, `coach`, `explanation`, `translation`, `narrative`, `other`). Unique `(user, subject_type, subject_id)` constraint gives upsert semantics; thumbs-down requires a comment. Admin triage page lists/filters/resolves feedback (`open → acknowledged|fixed|dismissed`). | + +Together these four stages mean every AI output can be gated, reviewed, iterated, and measured — closing the loop from prompt → generation → delivery → feedback → next prompt revision. + +#### Compliance (P3.2) + +New `encoach_api/controllers/gdpr.py` + `models/gdpr_erasure.py`: + +- `GET /api/gdpr/export` — JSON dump of the calling user's profile, entity memberships, exam attempts, answers, AI feedback, AI calls, tickets, coaching sessions. +- `POST /api/gdpr/delete` — requires `{"confirm": true}`; anonymises the partner PII, deletes personal feedback + coaching transcripts, strips free-text PII from retained exam answers, nulls `user_id` on AI logs, deactivates the `res.users` row, and writes a tombstone to `encoach.gdpr.erasure.request` for audit. Admin accounts are blocked from self-erasure. +- `frontend/src/pages/PrivacyCenter.tsx` — routed at `/student/privacy`, `/teacher/privacy`, `/admin/privacy`; download-my-data button produces a timestamped JSON file; erase flow is gated behind a `type "DELETE" to confirm` alert dialog. + +#### Paymob real checkout (P2.6) + +New `encoach_lms_api/controllers/paymob.py` + `models/paymob_order.py`: + +- `POST /api/payments/paymob/checkout` runs the three-step Paymob Accept flow (auth → order → payment key) and returns the hosted iframe URL. Our `encoach.paymob.order` row is created *before* the external call so partial failures don't lose orders. +- `POST /api/payments/paymob/webhook` verifies the HMAC-SHA512 signature over Paymob's canonical field sequence (`amount_cents|created_at|currency|…|success`). Mismatches return 401; duplicates are idempotent; unknown orders return 200 to stop Paymob retrying. +- Credentials read at request time from `ir.config_parameter` (`encoach.paymob.api_key`, `hmac_secret`, `integration_id`, `iframe_id`) so operators can rotate without restarting Odoo. +- `GET /api/paymob-orders` now reads real rows instead of returning `[]`. + +#### Internationalisation (P3.1) + +- `frontend/src/i18n/` bootstraps `i18next` + `i18next-browser-languagedetector` with nested feature-scoped namespaces. +- Initial locales: `en` (source of truth, typed with `Translations` interface) and `ar` (full translation of the same keys). +- RTL handling: when the active language is in `RTL_LANGS`, `document.documentElement.dir` is flipped to `rtl` so Tailwind utilities and Radix primitives adapt. +- `frontend/src/components/LanguageToggle.tsx` added next to the theme toggle in both `AdminLmsLayout` and `RoleLayout` headers. +- Loaded via `main.tsx` side-effect import so every route picks it up. + +#### CI scaffolding (P3.8) + +- `.github/workflows/ci.yml` with two jobs: + - **frontend**: `tsc --noEmit` → `eslint` → `vite build` → Playwright smoke tests against the Vite preview server; uploads a Playwright HTML report on failure. + - **backend**: spins up Postgres 16 as a service and runs the Odoo 19 container with `--test-enable --test-tags encoach_api` against a throwaway `encoach_ci` database. +- `backend/custom_addons/encoach_api/tests/test_health.py` — first `HttpCase`: asserts `/api/health` returns 200 and `/api/health/ready` returns 200 or 503 (never 5xx). +- `frontend/playwright.config.ts` + `frontend/e2e/login.spec.ts` — the login page renders the sign-in form; `/` redirects unauthenticated traffic to `/login`. +- New npm scripts: `test:e2e`, `test:e2e:install`. + +### 21.2 New DB tables (summary) + +| Table | Purpose | Added in | +|---|---|---| +| `encoach.ai.prompt` | Versioned prompt templates (`key`, `version`, `is_active`, `content`). | P3.4 | +| `encoach.ai.feedback` | Student thumbs up/down on AI output, with triage status. | P3.5 | +| `encoach.gdpr.erasure.request` | Tombstone row per right-to-erasure request. | P3.2 | +| `encoach.paymob.order` | Full lifecycle of every Paymob checkout, including the verified HMAC. | P2.6 | + +All have per-group `ir.model.access.csv` entries: admin read/write, authenticated-user read for metadata, create-only on feedback. + +### 21.3 New REST endpoints + +Everything requires a valid JWT unless noted; write routes additionally check `base.group_system` where relevant. + +``` +GET /api/ai/prompts list keys (latest per key) +GET /api/ai/prompts/:key/versions full history for a key +GET /api/ai/prompts/:id one version (full content) +POST /api/ai/prompts create new version (admin) +POST /api/ai/prompts/:id/activate flip active pointer (admin) +POST /api/ai/prompts/:id/render dry-run render with sample vars + +POST /api/ai/feedback upsert (user) +GET /api/ai/feedback/summary up/down counts + my rating +GET /api/ai/feedback admin triage list +POST /api/ai/feedback/:id/resolve admin triage resolve + +GET /api/gdpr/export full data-subject export (self) +POST /api/gdpr/delete right-to-erasure (self, confirm=true) + +POST /api/payments/paymob/checkout start a checkout +POST /api/payments/paymob/webhook HMAC-verified callback (public) +GET /api/payments/paymob/orders list my orders +GET /api/paymob-orders admin + self order list (rewired) +``` + +### 21.4 Frontend surface (new routes) + +``` +/admin/exam/review-queue → ExamReviewQueue.tsx +/admin/exam/review/:examId → ExamReviewDetail.tsx +/admin/ai/prompts → AIPromptEditor.tsx +/admin/ai/feedback → AIFeedbackTriage.tsx +/student|teacher|admin/privacy → PrivacyCenter.tsx +``` + +Plus the `AIFeedbackButtons` primitive (drop-in `/` that handles the upsert, dialog, and comment prompt) and `LanguageToggle` in both main layouts. + +### 21.5 Verification + +- `python3 -m compileall -q backend/custom_addons/encoach_ai backend/custom_addons/encoach_api backend/custom_addons/encoach_lms_api` — clean. +- `npx tsc --noEmit -p tsconfig.app.json` — clean. +- `npm run build` — succeeds; bundle analyser shows the same split profile as the §20 release (initial JS ≈ 250 KB gzipped on the login path); new features are lazy-loaded chunks. +- New modules do not depend on optional addons: all cross-model reads use `if model in request.env` guards so missing addons degrade gracefully instead of 500-ing. + +### 21.6 Configuration operators need to set + +Before the Paymob flow will actually charge anything, set in **Settings → Technical → System Parameters**: + +``` +encoach.paymob.api_key = +encoach.paymob.hmac_secret = +encoach.paymob.integration_id = +encoach.paymob.iframe_id = +``` + +Until those are set, `POST /api/payments/paymob/checkout` returns 503 with a descriptive message instead of silently crashing. + +### 21.7 Deferred (known) + +- `P1.2` — blanket-sudo audit + entity-isolation `ir.rule`s. Needs a coordinated frontend + data migration pass; deferred to a follow-up release. Current security model relies on JWT identity + controller-level `has_group` checks. +- Broader i18n coverage. Only the keys listed in `src/i18n/locales/en.ts` are translated today — extending to every page is a rolling job that can happen in small PRs now that the plumbing is in place. +- Playwright coverage is intentionally minimal (login + redirect); it exists as a safety net, not as full end-to-end coverage. diff --git a/docs/adr/0000-template.md b/docs/adr/0000-template.md new file mode 100644 index 00000000..7930785f --- /dev/null +++ b/docs/adr/0000-template.md @@ -0,0 +1,26 @@ +# ADR {NUMBER}: {TITLE} + +- **Status:** Proposed | Accepted | Superseded by ADR-XXXX +- **Date:** YYYY-MM-DD +- **Deciders:** @handle1, @handle2 + +## Context + +What is the problem? What forces are at play (technical, business, social)? +Keep this short; link out to source files or tickets for depth. + +## Decision + +What did we decide to do? Use imperative voice. Be specific enough that a new +engineer can understand the scope by reading this section alone. + +## Consequences + +- Positive: … +- Negative / trade-offs: … +- Follow-up work: … + +## Alternatives considered + +- **Option A** — why it was rejected. +- **Option B** — why it was rejected. diff --git a/docs/adr/0001-canonical-directory-layout.md b/docs/adr/0001-canonical-directory-layout.md new file mode 100644 index 00000000..784d1204 --- /dev/null +++ b/docs/adr/0001-canonical-directory-layout.md @@ -0,0 +1,52 @@ +# ADR 0001: Canonical `backend/` and `frontend/` directory layout + +- **Status:** Accepted +- **Date:** 2026-04-08 +- **Deciders:** Platform team + +## Context + +The repository historically contained two overlapping copies of server code: + +- `backend/custom_addons/` — Odoo addons, actively developed. +- `new_project/custom_addons/` — older snapshot, intermittently used for + one-off scripts and diverging subtly from `backend/`. + +Both paths showed up in `odoo.conf` variants, deploy scripts, and developer +onboarding docs. Contributors routinely edited the wrong copy, shipped drift, +or rediscovered that fixes "weren't taking" because they landed in the stale +tree. + +The same ambiguity existed implicitly on the client side: several prototype +apps lived under `new_project/frontend/` in addition to the main +`frontend/` workspace. + +## Decision + +Declare a single canonical layout: + +- `backend/custom_addons/**` — the only Odoo addons tree. All deployments, + tests, and Docker images read from here. +- `frontend/**` — the only React/Vite workspace. +- `new_project/` — **deprecated**. A `DEPRECATED.md` is committed inside the + directory explaining the policy; no new files may be added. + +New `encoach_*` modules and frontend features MUST land under the canonical +paths. Existing imports, Dockerfiles, and docs were updated accordingly. + +## Consequences + +- Positive: one obvious place to work, deterministic CI, no drift. +- Positive: `PROJECT_SUMMARY.md` and this README become credible onboarding + material. +- Negative: scripts that hard-coded `new_project/` paths had to be migrated + (one-time cost, done). + +## Alternatives considered + +- **Delete `new_project/` outright.** Rejected for now because a few historic + tarballs and experiment scripts still reference it; leaving the tree with a + `DEPRECATED.md` marker lets us retire it in a later cleanup pass without + blocking the hardening release. +- **Rename `backend/` to `odoo/` to mirror Odoo's own layout.** Rejected + because `odoo/` is already used for the upstream Odoo source checkout. diff --git a/docs/adr/0002-jwt-refresh-token-flow.md b/docs/adr/0002-jwt-refresh-token-flow.md new file mode 100644 index 00000000..ec442e42 --- /dev/null +++ b/docs/adr/0002-jwt-refresh-token-flow.md @@ -0,0 +1,66 @@ +# ADR 0002: JWT access + refresh tokens with revocation ledger + +- **Status:** Accepted +- **Date:** 2026-04-09 +- **Deciders:** Platform team, Security + +## Context + +Originally `/api/login` issued a single long-lived JWT (24h+) stored in +`localStorage`. This gave us three problems: + +1. **No revocation.** A leaked token was valid until it expired; there was no + server-side way to invalidate it short of rotating the global JWT secret. +2. **Silent logouts.** When the token expired mid-session the browser just + started receiving 401s with no graceful recovery path. +3. **Surface area.** Every endpoint accepted the same kind of token, so a + token intended for a refresh use-case could be replayed as a full API + credential. + +## Decision + +Adopt a two-token flow: + +- **Access token** — 1 h TTL, stateless, carries `type: "access"`. Sent on + every request as `Authorization: Bearer …`. `validate_token()` in + `encoach_api.controllers.base` rejects tokens whose `type` is anything other + than `"access"`. +- **Refresh token** — 7 d TTL, carries `type: "refresh"` and a unique `jti`. + Every issued refresh token is logged in a new `encoach.jwt.token` Odoo model + (the revocation ledger) with fields for `user_id`, `issued_at`, `expires_at`, + `last_used_at`, `revoked`, `user_agent`, `remote_ip`. + +Endpoints: + +- `POST /api/login` — returns `access_token`, `refresh_token`, `expires_in`. +- `POST /api/auth/refresh` — validates the refresh token, revokes the old + ledger row (rotation), and issues a fresh access + refresh pair. +- `POST /api/logout` — revokes the supplied refresh token's ledger row. + +The frontend (`frontend/src/lib/api-client.ts`) handles rotation +transparently: on 401 it calls `/api/auth/refresh` once (coalesced across +concurrent requests) and retries the original request. If refresh fails, all +tokens are cleared and the user is redirected to `/login`. + +A cron (`encoach_api.data.cron`) purges expired ledger rows daily. + +## Consequences + +- Positive: revocation works — logout or compromise clears the server-side + ledger entry and the refresh token is instantly unusable. +- Positive: short access-token TTL limits the blast radius of a leaked Bearer. +- Positive: the refresh flow is invisible to users; no more mid-session + logouts. +- Negative: one extra DB round-trip per refresh. Mitigated by the short-lived + access token and the fact that the ledger is indexed on `jti` + `user_id`. +- Follow-up: move ledger cleanup from a time-based cron to an event-based + cleanup if the table ever grows past a few hundred thousand rows. + +## Alternatives considered + +- **Opaque session tokens with a Redis store.** Rejected — adds an operational + dependency (Redis) that the rest of the stack does not yet require, and + complicates horizontal scaling. +- **Single JWT with short TTL + silent re-login.** Rejected — requires the + client to store credentials or an SSO cookie, neither of which we want in + `localStorage`. diff --git a/docs/adr/0003-paginated-response-envelope.md b/docs/adr/0003-paginated-response-envelope.md new file mode 100644 index 00000000..17e256bc --- /dev/null +++ b/docs/adr/0003-paginated-response-envelope.md @@ -0,0 +1,60 @@ +# ADR 0003: Canonical paginated response envelope + +- **Status:** Accepted +- **Date:** 2026-04-09 +- **Deciders:** Platform team, Frontend team + +## Context + +Different Odoo controllers returned paginated data in at least three shapes: + +- `{ data: [...], total: N, page, limit }` +- `{ results: [...], count: N }` +- `[...]` (bare array, no totals) + +The frontend grew defensive code paths to handle all three, and every new +endpoint risked inventing a fourth shape. + +## Decision + +Every list endpoint MUST return the canonical envelope produced by +`encoach_api.controllers.base.paginated_envelope`: + +```json +{ + "items": [ … ], + "data": [ … ], + "total": 123, + "page": 1, + "size": 20 +} +``` + +- `items` is the canonical field name. New code reads from `items`. +- `data` mirrors `items` for backwards compatibility with older callers and + can be removed once every consumer migrates. +- `total` is the total number of matching records across all pages. +- `page` is 1-indexed. +- `size` is the requested page size (capped server-side). + +On the frontend, `PaginatedResponse` in `frontend/src/types/common.ts` +exposes both `items` and an optional `data`, and service methods +(`users.service.ts`, `lms.service.ts`, etc.) construct a clean +`PaginatedResponse` object from whatever the server returns so that UI code +never sees the legacy fields. + +## Consequences + +- Positive: frontend code is simpler and typesafe — read `items`, done. +- Positive: OpenAPI spec advertises one consistent shape across endpoints. +- Negative: one additional key (`data`) is duplicated in responses. Cheap + (same reference, no JSON bloat) and easy to delete later. + +## Alternatives considered + +- **Follow JSON:API's `{ data, meta, links }` convention.** Rejected as + overkill: we don't use HATEOAS, and the extra indirection would force a + rewrite of every existing consumer. +- **Return a bare array + pagination headers.** Rejected because Odoo's + controller helpers make setting custom headers awkward and because it hides + totals from curl/Postman users. diff --git a/docs/adr/0004-rag-metadata-and-chunking.md b/docs/adr/0004-rag-metadata-and-chunking.md new file mode 100644 index 00000000..c12ff811 --- /dev/null +++ b/docs/adr/0004-rag-metadata-and-chunking.md @@ -0,0 +1,67 @@ +# ADR 0004: RAG metadata + chunking for vector store + +- **Status:** Accepted +- **Date:** 2026-04-09 +- **Deciders:** AI team, Platform team + +## Context + +The first cut of the vector store (`encoach_vector`) stored one embedding +per source record, keyed only by `(model, res_id)`. This had two problems: + +1. **Long documents dominated similarity scores.** A 20 000-character lesson + would embed as one vector and out-vote shorter, more relevant passages. +2. **No tenancy filtering.** Retrieval could not be scoped to a particular + course, subject, entity, or taxonomy topic, which meant RAG pulled content + from unrelated tenants on multi-entity deployments. + +The quality gate (`encoach_quality_gate`) also needed a way to deduplicate +re-ingested content so that re-running the indexer did not explode the table. + +## Decision + +Extend `encoach.vector.embedding` with RAG metadata columns: + +| Field | Purpose | +|-------|---------| +| `course_id` | Scope to a specific course. | +| `subject_id` | Scope to a subject/domain. | +| `entity_id` | Tenancy filter — critical for institutional deployments. | +| `taxonomy` | Free-form tag (e.g. `"IELTS/writing/task1"`). | +| `content_hash` | SHA-256 of the raw chunk; used for dedup. | +| `chunk_index`, `chunk_total` | Position in the parent document. | + +Chunking policy (see `encoach_vector.services.embedding_service`): + +- Content ≤ 2 000 chars → embedded as a single chunk. +- Content > 2 000 chars → split on paragraph boundaries with ~200-char + overlap, each chunk embedded individually. +- Each chunk stores its `content_hash`; the uniqueness constraint is + `(model, res_id, chunk_index, content_hash)` so re-indexing is idempotent. + +The indexer (`encoach_vector.services.indexer`) declares per-model metadata +mapping (which field feeds `course_id`, which feeds `subject_id`, etc.) so +adding a new source model is a single config entry. + +`similarity_search` accepts any subset of the metadata as a filter and +applies it as a SQL `WHERE` clause before the vector distance computation. + +## Consequences + +- Positive: retrieval quality improves dramatically on long documents. +- Positive: multi-tenant deployments can scope RAG to a single entity. +- Positive: re-indexing is safe (idempotent) and cheap. +- Negative: the embedding table grows roughly linearly with document length. + Mitigated by the `content_hash` dedup and by keeping only the latest + revision per source record. +- Follow-up: expose a management action to purge embeddings for a retired + course or entity. + +## Alternatives considered + +- **Use an external vector DB (Pinecone, Weaviate).** Rejected — pgvector is + already in the Postgres image, keeping ops surface small. Can be revisited + if we outgrow it. +- **Chunk-per-sentence instead of paragraph.** Rejected — too many tiny + chunks, each losing context; paragraph-sized chunks strike a better + recall/precision balance for our domain. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..67119b61 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,22 @@ +# Architecture Decision Records + +This folder contains lightweight ADRs documenting significant architectural +decisions made on the EnCoach platform. Each ADR is numbered, dated, and +immutable once "Accepted" — if a decision is revisited, open a new ADR that +supersedes the old one instead of rewriting history. + +## Index + +| # | Title | Status | +|---|-------|--------| +| [0001](0001-canonical-directory-layout.md) | Canonical `backend/` and `frontend/` directory layout | Accepted | +| [0002](0002-jwt-refresh-token-flow.md) | JWT access + refresh tokens with revocation ledger | Accepted | +| [0003](0003-paginated-response-envelope.md) | Canonical paginated response envelope | Accepted | +| [0004](0004-rag-metadata-and-chunking.md) | RAG metadata + chunking for vector store | Accepted | + +## Writing a new ADR + +1. Copy [`0000-template.md`](0000-template.md) to the next number. +2. Fill in **Context**, **Decision**, **Consequences**. +3. Keep it short (1 page). Link to source files or PRs for detail. +4. Update the index above and open a PR. diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts new file mode 100644 index 00000000..3be2f7ca --- /dev/null +++ b/frontend/e2e/login.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +/** + * Smoke test: the app loads, serves the login page, and the main form + * controls are present and reachable. We deliberately avoid hitting the + * real backend — this is a lightweight sanity check that catches broken + * routing / bundle / asset problems before they reach production. + */ +test("login page renders the sign-in form", async ({ page }) => { + await page.goto("/login"); + await expect( + page.getByRole("heading", { name: /sign in|login|welcome/i }), + ).toBeVisible({ timeout: 10_000 }); + + const email = page.getByLabel(/email/i); + const password = page.getByLabel(/password/i); + await expect(email).toBeVisible(); + await expect(password).toBeVisible(); + + await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible(); +}); + +test("root redirects to login for anonymous users", async ({ page }) => { + const response = await page.goto("/"); + expect(response?.ok()).toBeTruthy(); + await page.waitForURL(/\/login/i, { timeout: 10_000 }); +}); diff --git a/frontend/index.html b/frontend/index.html index c54f1021..ea5a3388 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,8 +6,14 @@ EnCoach — IELTS & English Learning Platform + + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f5e02efc..497f4fad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,8 @@ "cmdk": "^1.1.1", "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", + "i18next": "^26.0.6", + "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -49,6 +51,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.61.1", + "react-i18next": "^17.0.4", "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", @@ -60,6 +63,7 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@playwright/test": "^1.59.1", "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", @@ -905,6 +909,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5480,6 +5500,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -5509,6 +5538,46 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "26.0.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz", + "integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6215,6 +6284,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -6547,6 +6663,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz", + "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -7369,7 +7512,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8164,6 +8307,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 04b23658..230daf3a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install --with-deps chromium" }, "dependencies": { "@hookform/resolvers": "^3.10.0", @@ -47,6 +49,8 @@ "cmdk": "^1.1.1", "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", + "i18next": "^26.0.6", + "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -54,6 +58,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.61.1", + "react-i18next": "^17.0.4", "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", @@ -65,9 +70,10 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@playwright/test": "^1.59.1", + "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", - "@tailwindcss/typography": "^0.5.16", "@types/node": "^22.16.5", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..66b3b3ab --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for EnCoach frontend smoke tests. + * + * By default we run against a Vite preview server on localhost:4173. In CI + * this is started by the `playwright` step in .github/workflows/ci.yml after + * `npm run build`. + * + * Override the base URL with `PLAYWRIGHT_BASE_URL=https://... npx playwright + * test` to run against a deployed environment. + */ +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:4173"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [["github"], ["list"]] : [["list"]], + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER + ? undefined + : { + command: "npm run preview -- --host 127.0.0.1 --port 4173", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 00000000..398952a9 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 00000000..9943934b Binary files /dev/null and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 00000000..0c7598e7 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 3c01d697..2694fe4f 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/logo-icon.png b/frontend/public/logo-icon.png new file mode 100644 index 00000000..fc5e9d90 Binary files /dev/null and b/frontend/public/logo-icon.png differ diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 00000000..d832f1d4 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d4adc81..148daa80 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,5 @@ +import { lazy, Suspense } from "react"; +import { ThemeProvider } from "next-themes"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -8,145 +10,166 @@ import ProtectedRoute from "@/components/ProtectedRoute"; import StudentLayout from "@/components/StudentLayout"; import TeacherLayout from "@/components/TeacherLayout"; import AdminLmsLayout from "@/components/AdminLmsLayout"; -import Login from "@/pages/Login"; -import Register from "@/pages/Register"; -import EmailVerification from "@/pages/EmailVerification"; -import OnboardingWizard from "@/pages/OnboardingWizard"; -import ForgotPassword from "@/pages/ForgotPassword"; -import ResetPassword from "@/pages/ResetPassword"; -import ScoreVerification from "@/pages/ScoreVerification"; -// Original platform pages -import AdminDashboard from "@/pages/AdminDashboard"; -import UsersPage from "@/pages/UsersPage"; -import EntitiesPage from "@/pages/EntitiesPage"; -import AssignmentsPage from "@/pages/AssignmentsPage"; -import ExamsListPage from "@/pages/ExamsListPage"; -import ExamStructuresPage from "@/pages/ExamStructuresPage"; -import RubricsPage from "@/pages/RubricsPage"; -import GenerationPage from "@/pages/GenerationPage"; -import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage"; -import ClassroomsPage from "@/pages/ClassroomsPage"; -import StudentPerformancePage from "@/pages/StudentPerformancePage"; -import StatsCorporatePage from "@/pages/StatsCorporatePage"; -import RecordPage from "@/pages/RecordPage"; -import VocabularyPage from "@/pages/VocabularyPage"; -import GrammarPage from "@/pages/GrammarPage"; -import PaymentRecordPage from "@/pages/PaymentRecordPage"; -import TicketsPage from "@/pages/TicketsPage"; -import SettingsPage from "@/pages/SettingsPage"; -import ProfilePage from "@/pages/ProfilePage"; -import ExamPage from "@/pages/ExamPage"; -// Student pages -import StudentDashboard from "@/pages/student/StudentDashboard"; -import StudentCourses from "@/pages/student/StudentCourses"; -import StudentCourseDetail from "@/pages/student/StudentCourseDetail"; -import StudentAssignments from "@/pages/student/StudentAssignments"; -import StudentGrades from "@/pages/student/StudentGrades"; -import StudentAttendance from "@/pages/student/StudentAttendance"; -import StudentTimetable from "@/pages/student/StudentTimetable"; -import StudentProfile from "@/pages/student/StudentProfile"; -// Adaptive learning pages -import SubjectSelection from "@/pages/student/SubjectSelection"; -import DiagnosticTest from "@/pages/student/DiagnosticTest"; -import ProficiencyProfile from "@/pages/student/ProficiencyProfile"; -import LearningPlanPage from "@/pages/student/LearningPlan"; -import TopicLearning from "@/pages/student/TopicLearning"; -// Teacher pages -import TeacherDashboard from "@/pages/teacher/TeacherDashboard"; -import TeacherCourses from "@/pages/teacher/TeacherCourses"; -import CourseBuilder from "@/pages/teacher/CourseBuilder"; -import TeacherAssignments from "@/pages/teacher/TeacherAssignments"; -import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail"; -import TeacherAttendance from "@/pages/teacher/TeacherAttendance"; -import TeacherStudents from "@/pages/teacher/TeacherStudents"; -import TeacherTimetable from "@/pages/teacher/TeacherTimetable"; -import TeacherProfile from "@/pages/teacher/TeacherProfile"; -import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings"; -// Admin LMS pages -import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard"; -import AdminCourses from "@/pages/admin/AdminCourses"; -import AdminStudents from "@/pages/admin/AdminStudents"; -import AdminTeachers from "@/pages/admin/AdminTeachers"; -import AdminBatches from "@/pages/admin/AdminBatches"; -import AdminBatchDetail from "@/pages/admin/AdminBatchDetail"; -import AdminTimetable from "@/pages/admin/AdminTimetable"; -import AdminReports from "@/pages/admin/AdminReports"; -import AdminSettings from "@/pages/admin/AdminSettings"; -import AdminProfileLms from "@/pages/admin/AdminProfile"; -import TaxonomyManager from "@/pages/admin/TaxonomyManager"; -import ResourceManager from "@/pages/admin/ResourceManager"; -import AcademicYearManager from "@/pages/admin/AcademicYearManager"; -import DepartmentManager from "@/pages/admin/DepartmentManager"; -import AdmissionList from "@/pages/admin/AdmissionList"; -import AdmissionDetail from "@/pages/admin/AdmissionDetail"; -import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage"; -import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions"; -import MarksheetManager from "@/pages/admin/MarksheetManager"; -import AdminStudentLeave from "@/pages/admin/AdminStudentLeave"; -import AdminFees from "@/pages/admin/AdminFees"; -import AdminLessons from "@/pages/admin/AdminLessons"; -import AdminGradebook from "@/pages/admin/AdminGradebook"; -import AdminStudentProgress from "@/pages/admin/AdminStudentProgress"; -import AdminLibrary from "@/pages/admin/AdminLibrary"; -import AdminActivities from "@/pages/admin/AdminActivities"; -import AdminFacilities from "@/pages/admin/AdminFacilities"; -import AdmissionApplication from "@/pages/AdmissionApplication"; -import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage"; -// Courseware pages -import CourseChapters from "@/pages/teacher/CourseChapters"; -import ChapterDetail from "@/pages/teacher/ChapterDetail"; -import AiWorkbench from "@/pages/teacher/AiWorkbench"; -import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard"; -import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements"; -import StudentChapterView from "@/pages/student/StudentChapterView"; -import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard"; -import StudentAnnouncements from "@/pages/student/StudentAnnouncements"; -import StudentMessages from "@/pages/student/StudentMessages"; -import StudentJourney from "@/pages/student/StudentJourney"; -import AiEnglishCourse from "@/pages/student/AiEnglishCourse"; -import AiIeltsCourse from "@/pages/student/AiIeltsCourse"; -import ExamSession from "@/pages/student/ExamSession"; -import ExamStatus from "@/pages/student/ExamStatus"; -import ExamResults from "@/pages/student/ExamResults"; -import GapAnalysis from "@/pages/student/GapAnalysis"; -import CourseDelivery from "@/pages/student/CourseDelivery"; -import GradingQueue from "@/pages/admin/GradingQueue"; -import CourseConfig from "@/pages/admin/CourseConfig"; -import ModuleBuilder from "@/pages/admin/ModuleBuilder"; -import CourseProgress from "@/pages/teacher/CourseProgress"; -import PlacementBriefing from "@/pages/student/PlacementBriefing"; -import PlacementTest from "@/pages/student/PlacementTest"; -import PlacementResults from "@/pages/student/PlacementResults"; -import PlacementAccess from "@/pages/student/PlacementAccess"; -import FaqManager from "@/pages/admin/FaqManager"; -import NotificationRules from "@/pages/admin/NotificationRules"; -import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig"; -import RolesPermissions from "@/pages/admin/RolesPermissions"; -import AuthorityMatrix from "@/pages/admin/AuthorityMatrix"; -import UserRoles from "@/pages/admin/UserRoles"; -import BulkStudentUpload from "@/pages/admin/BulkStudentUpload"; -import CredentialDashboard from "@/pages/admin/CredentialDashboard"; -import AiEnglishQuality from "@/pages/admin/AiEnglishQuality"; -import AiEnglishTaxonomy from "@/pages/admin/AiEnglishTaxonomy"; -import AiIeltsValidation from "@/pages/admin/AiIeltsValidation"; -import AdaptiveDashboard from "@/pages/admin/AdaptiveDashboard"; -import AdaptiveStudentDetail from "@/pages/admin/AdaptiveStudentDetail"; -import LevelMappingConfig from "@/pages/admin/LevelMappingConfig"; -import WhiteLabelBranding from "@/pages/admin/WhiteLabelBranding"; -import ScoreApprovalQueue from "@/pages/admin/ScoreApprovalQueue"; -import ExamTemplateSelection from "@/pages/admin/ExamTemplateSelection"; -import IeltsExamCreate from "@/pages/admin/IeltsExamCreate"; -import IeltsSkillConfig from "@/pages/admin/IeltsSkillConfig"; -import IeltsContentPool from "@/pages/admin/IeltsContentPool"; -import IeltsExamValidation from "@/pages/admin/IeltsExamValidation"; -import CustomExamCreate from "@/pages/admin/CustomExamCreate"; -import OfficialExamAccess from "@/pages/OfficialExamAccess"; -import FaqPage from "@/pages/FaqPage"; -import NotFound from "@/pages/NotFound"; import { queryClient } from "@/lib/query-client"; import { Button } from "@/components/ui/button"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +// ----------------------------------------------------------------------------- +// Lazy-loaded route pages +// ----------------------------------------------------------------------------- +// Keeping the initial bundle small matters on slow networks / modest hardware +// (many teachers/students open the app from school Wi-Fi). Each page below is +// split into its own chunk and only fetched when the user hits the matching +// route. The shell (layouts, providers, router) is still eagerly imported so +// the first paint stays snappy. + +// Auth +const Login = lazy(() => import("@/pages/Login")); +const Register = lazy(() => import("@/pages/Register")); +const EmailVerification = lazy(() => import("@/pages/EmailVerification")); +const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard")); +const ForgotPassword = lazy(() => import("@/pages/ForgotPassword")); +const ResetPassword = lazy(() => import("@/pages/ResetPassword")); +const ScoreVerification = lazy(() => import("@/pages/ScoreVerification")); + +// Original platform pages +const AdminDashboard = lazy(() => import("@/pages/AdminDashboard")); +const UsersPage = lazy(() => import("@/pages/UsersPage")); +const EntitiesPage = lazy(() => import("@/pages/EntitiesPage")); +const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage")); +const ExamsListPage = lazy(() => import("@/pages/ExamsListPage")); +const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage")); +const RubricsPage = lazy(() => import("@/pages/RubricsPage")); +const GenerationPage = lazy(() => import("@/pages/GenerationPage")); +const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage")); +const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage")); +const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage")); +const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage")); +const RecordPage = lazy(() => import("@/pages/RecordPage")); +const VocabularyPage = lazy(() => import("@/pages/VocabularyPage")); +const GrammarPage = lazy(() => import("@/pages/GrammarPage")); +const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage")); +const TicketsPage = lazy(() => import("@/pages/TicketsPage")); +const SettingsPage = lazy(() => import("@/pages/SettingsPage")); + +// Student pages +const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard")); +const StudentCourses = lazy(() => import("@/pages/student/StudentCourses")); +const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail")); +const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments")); +const StudentGrades = lazy(() => import("@/pages/student/StudentGrades")); +const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance")); +const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable")); +const StudentProfile = lazy(() => import("@/pages/student/StudentProfile")); + +// Adaptive learning pages +const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection")); +const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest")); +const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile")); +const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan")); +const TopicLearning = lazy(() => import("@/pages/student/TopicLearning")); + +// Teacher pages +const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard")); +const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses")); +const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder")); +const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments")); +const TeacherAssignmentDetail = lazy(() => import("@/pages/teacher/TeacherAssignmentDetail")); +const TeacherAttendance = lazy(() => import("@/pages/teacher/TeacherAttendance")); +const TeacherStudents = lazy(() => import("@/pages/teacher/TeacherStudents")); +const TeacherTimetable = lazy(() => import("@/pages/teacher/TeacherTimetable")); +const TeacherProfile = lazy(() => import("@/pages/teacher/TeacherProfile")); +const TeacherLibrary = lazy(() => import("@/pages/teacher/TeacherLibrary")); +const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings")); + +// Admin LMS pages +const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard")); +const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses")); +const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents")); +const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers")); +const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches")); +const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail")); +const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable")); +const AdminReports = lazy(() => import("@/pages/admin/AdminReports")); +const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings")); +const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile")); +const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager")); +const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager")); +const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager")); +const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager")); +const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList")); +const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail")); +const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage")); +const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions")); +const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager")); +const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave")); +const AdminFees = lazy(() => import("@/pages/admin/AdminFees")); +const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons")); +const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook")); +const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress")); +const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary")); +const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities")); +const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities")); +const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication")); +const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage")); + +// Courseware pages +const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters")); +const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail")); +const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench")); +const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard")); +const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements")); +const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView")); +const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard")); +const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements")); +const StudentMessages = lazy(() => import("@/pages/student/StudentMessages")); +const StudentJourney = lazy(() => import("@/pages/student/StudentJourney")); +const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse")); +const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse")); +const ExamSession = lazy(() => import("@/pages/student/ExamSession")); +const ExamStatus = lazy(() => import("@/pages/student/ExamStatus")); +const ExamResults = lazy(() => import("@/pages/student/ExamResults")); +const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis")); +const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery")); +const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue")); +const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig")); +const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder")); +const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress")); +const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing")); +const PlacementTest = lazy(() => import("@/pages/student/PlacementTest")); +const PlacementResults = lazy(() => import("@/pages/student/PlacementResults")); +const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess")); +const FaqManager = lazy(() => import("@/pages/admin/FaqManager")); +const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules")); +const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig")); +const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions")); +const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix")); +const UserRoles = lazy(() => import("@/pages/admin/UserRoles")); +const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload")); +const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard")); +const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality")); +const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy")); +const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation")); +const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard")); +const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail")); +const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig")); +const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding")); +const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue")); +const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection")); +const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate")); +const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig")); +const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool")); +const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation")); +const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate")); +const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue")); +const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail")); +const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor")); +const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage")); +const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter")); +const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess")); +const FaqPage = lazy(() => import("@/pages/FaqPage")); +const NotFound = lazy(() => import("@/pages/NotFound")); + function StudentSubscriptionPlaceholder() { const navigate = useNavigate(); return ( @@ -157,14 +180,30 @@ function StudentSubscriptionPlaceholder() { ); } +function RouteFallback() { + return ( +
+
+
+ ); +} + const App = () => ( + + }> {/* Auth routes */} } /> @@ -199,6 +238,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> @@ -222,10 +262,11 @@ const App = () => ( {/* Teacher routes */} - }> + }> }> } /> } /> + } /> } /> } /> } /> @@ -233,12 +274,13 @@ const App = () => ( } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> + } /> } /> } /> @@ -261,6 +303,7 @@ const App = () => ( } /> } /> } /> + } /> {/* Original academic pages */} } /> } /> @@ -283,13 +326,16 @@ const App = () => ( } /> } /> } /> - } /> } /> } /> } /> } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> {/* Institutional LMS pages */} @@ -338,10 +384,12 @@ const App = () => ( } /> } /> + + ); diff --git a/frontend/src/components/AIFeedbackButtons.tsx b/frontend/src/components/AIFeedbackButtons.tsx new file mode 100644 index 00000000..b96e8515 --- /dev/null +++ b/frontend/src/components/AIFeedbackButtons.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from "react"; +import { ThumbsDown, ThumbsUp } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { + useAIFeedbackSummary, + useSubmitAIFeedback, +} from "@/hooks/queries/useAIFeedback"; +import { cn } from "@/lib/utils"; +import type { + AIFeedbackRating, + AIFeedbackSubjectType, +} from "@/types/ai-feedback"; + +export interface AIFeedbackButtonsProps { + subjectType: AIFeedbackSubjectType; + subjectId: number; + promptKey?: string; + promptVersion?: number; + aiLogId?: number; + entityId?: number; + courseId?: number; + size?: "sm" | "md"; + className?: string; +} + +/** Thumbs up/down widget for any AI-generated artefact. + * + * Always renders the same UI regardless of whether the user has rated the + * artefact before — a previous rating will show as highlighted. Clicking the + * same button again is a no-op; clicking the opposite button updates the + * server-side row in place. A thumbs-down always prompts the user for a short + * comment (required by the backend). + */ +export function AIFeedbackButtons({ + subjectType, + subjectId, + promptKey, + promptVersion, + aiLogId, + entityId, + courseId, + size = "sm", + className, +}: AIFeedbackButtonsProps) { + const summary = useAIFeedbackSummary(subjectType, subjectId); + const submit = useSubmitAIFeedback(); + + const [downOpen, setDownOpen] = useState(false); + const [comment, setComment] = useState(""); + + useEffect(() => { + if (downOpen) { + setComment(summary.data?.my_comment ?? ""); + } + }, [downOpen, summary.data?.my_comment]); + + const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null; + + const submitUp = async () => { + if (myRating === "up") return; + try { + await submit.mutateAsync({ + subject_type: subjectType, + subject_id: subjectId, + rating: "up", + prompt_key: promptKey, + prompt_version: promptVersion, + ai_log_id: aiLogId, + entity_id: entityId, + course_id: courseId, + }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to submit"); + } + }; + + const submitDown = async () => { + if (!comment.trim()) { + toast.error("Please tell us what was wrong."); + return; + } + try { + await submit.mutateAsync({ + subject_type: subjectType, + subject_id: subjectId, + rating: "down", + comment: comment.trim(), + prompt_key: promptKey, + prompt_version: promptVersion, + ai_log_id: aiLogId, + entity_id: entityId, + course_id: courseId, + }); + setDownOpen(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to submit"); + } + }; + + const btnSize = size === "sm" ? "sm" : "default"; + const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"; + + return ( +
+ + + + + + + What went wrong? + +