4 Commits

Author SHA1 Message Date
Yamen Ahmad
93c530eef2 chore(ci,docs): GitHub Actions, ADRs, README overhaul, §21 Hardening Release
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
- .github/workflows/ci.yml: two jobs — frontend (tsc --noEmit, lint, build,
  Playwright) and backend (Postgres 16 + odoo:19 --test-enable
  --test-tags encoach_api) — catches regressions before merge.
- docs/adr/: start an Architecture Decision Record trail with
  0001 canonical directory layout, 0002 JWT refresh flow,
  0003 paginated response envelope, 0004 RAG metadata + chunking.
- docs/PROJECT_SUMMARY.md §21 Hardening Release: full recap of the AI
  quality loop, compliance, Paymob, i18n, and CI work shipped in this
  drop, plus new DB tables, REST routes, frontend routes, verification
  results, and operator-facing configuration.
- README.md refreshed for the v4 split-repo doctrine and the new feature
  surface.
- new_project/DEPRECATED.md: formal retirement notice pointing at
  backend/ as the canonical tree.

Made-with: Cursor
2026-04-19 14:16:47 +04:00
Yamen Ahmad
e70a2854f4 feat(frontend): Phase 2/3 hardening release
Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
  the project-manager PNG in a separate commit).

Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
  and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.

Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
  from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.

Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
  LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
  by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
  pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
  AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
  services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
  and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
  smoke tests, npm scripts test:e2e / test:e2e:install.

Made-with: Cursor
2026-04-19 14:16:32 +04:00
Yamen Ahmad
dcf5ea6941 feat(backend): Phase 2/3 hardening release
Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
2026-04-19 14:16:09 +04:00
Yamen Ahmad
47d09a3ce5 chore(branding): adopt project-manager EnCoach logo across login and sidebars
- Replace public/logo-icon.png with the approved EnCoach wordmark
  (icon + "EnCoach" + tagline "Unlock your potential with AI powered platform").
- Login page shows the full branded logo at h-36 and drops the duplicate
  text heading since the wordmark is now baked into the asset.
- Student/Teacher/Admin sidebars show a small rounded icon when collapsed
  and the full wordmark at h-14 when expanded, removing the previous
  duplicated "EnCoach" text span.
- og:image in index.html already points at /logo-icon.png so social cards
  pick up the new branding automatically.

Made-with: Cursor
2026-04-19 14:15:41 +04:00
159 changed files with 8729 additions and 1380 deletions

109
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

View File

@@ -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

267
README.md
View File

@@ -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<T>` 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).

View File

@@ -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"],
},

View File

@@ -1,3 +1,5 @@
from . import ai_controller
from . import coach_controller
from . import media_controller
from . import prompt_controller
from . import feedback_controller

View File

@@ -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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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/<int:domain_id>/ai-suggest", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/domains/<int:domain_id>/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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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:
@@ -315,7 +333,8 @@ class AIController(http.Controller):
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="public", methods=["POST"], csrf=False)
@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()
@@ -450,7 +469,8 @@ class AIController(http.Controller):
]
# ── Exam generation — GenerationPage.tsx ──
@http.route("/api/exam/<string:module>/generate", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/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()
@@ -1252,7 +1272,8 @@ class AIController(http.Controller):
return {"questions": questions}
# ── POST /api/exam/generation/submit — create exam from generation page ──
@http.route("/api/exam/generation/submit", type="http", auth="public", 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()
@@ -1265,12 +1286,40 @@ class AIController(http.Controller):
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
@@ -1304,13 +1353,14 @@ class AIController(http.Controller):
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
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()),
@@ -1344,6 +1394,27 @@ class AIController(http.Controller):
"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():
@@ -1381,7 +1452,7 @@ class AIController(http.Controller):
for ex in (passage.get("exercises") or []):
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
opts = ex.get("options", [])
q = Question.create({
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,
@@ -1392,7 +1463,7 @@ class AIController(http.Controller):
"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 []
@@ -1402,7 +1473,7 @@ class AIController(http.Controller):
for ex in (s_data.get("exercises") or []):
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
opts = ex.get("options", [])
q = Question.create({
q = _create_question({
"skill": "listening",
"source_type": "audio",
"question_type": q_type,
@@ -1413,12 +1484,12 @@ class AIController(http.Controller):
"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 = Question.create({
q = _create_question({
"skill": "writing",
"source_type": "writing_prompt",
"question_type": "short_answer",
@@ -1429,12 +1500,12 @@ class AIController(http.Controller):
"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 = Question.create({
q = _create_question({
"skill": "speaking",
"source_type": "speaking_card",
"question_type": "short_answer",
@@ -1445,7 +1516,7 @@ class AIController(http.Controller):
"difficulty": q_difficulty,
"status": "active",
"ai_generated": True,
})
}, cefr_level=cefr_level)
question_ids.append(q.id)
if question_ids:
@@ -1455,18 +1526,37 @@ class AIController(http.Controller):
})
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="public", 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", [])
@@ -1481,7 +1571,8 @@ class AIController(http.Controller):
return _json_response({"applied": 0, "error": str(e)}, 500)
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/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()
@@ -1521,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="public", 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:
@@ -1541,7 +1633,8 @@ class AIController(http.Controller):
return _json_response({"materials": [], "error": str(e)})
# ── Topic content generation — adaptive ──
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def topic_generate_content(self, topic_id, **kw):
body = _get_json()
try:

View File

@@ -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="public", 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="public", 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="public", 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="public", 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="public", 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="public", 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:

View File

@@ -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/<id>/resolve — admin triage
# ------------------------------------------------------------------
@http.route(
'/api/ai/feedback/<int:feedback_id>/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)

View File

@@ -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="public", 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="public", 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="public", 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="public", 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/<string:video_id>", type="http", auth="public", methods=["GET"], csrf=False)
@http.route("/api/exam/avatar/video/<string:video_id>", 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="public", 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:

View File

@@ -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/<key>/versions
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<string:key>/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/<int:prompt_id>
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>',
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/<id>/activate
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/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/<id>/render — dry-run render with sample variables
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/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)

View File

@@ -1,3 +1,5 @@
from . import ai_settings
from . import ai_log
from . import ai_prompt
from . import ai_feedback
from . import constants

View File

@@ -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 '<type>:<id>' 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.")

View File

@@ -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 + "}"

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_log_admin encoach.ai.log admin model_encoach_ai_log base.group_system 1 1 1 1
3 access_ai_log_user encoach.ai.log user model_encoach_ai_log base.group_user 1 0 1 0
4 access_ai_prompt_admin encoach.ai.prompt admin model_encoach_ai_prompt base.group_system 1 1 1 1
5 access_ai_prompt_user encoach.ai.prompt user model_encoach_ai_prompt base.group_user 1 0 0 0
6 access_ai_feedback_admin encoach.ai.feedback admin model_encoach_ai_feedback base.group_system 1 1 1 1
7 access_ai_feedback_user encoach.ai.feedback user model_encoach_ai_feedback base.group_user 1 1 1 0

View File

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

View File

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

View File

@@ -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

View File

@@ -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": {
"<skill>": {
"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 "{}"

View File

@@ -1 +1,3 @@
from . import models
from . import controllers
from . import utils # exposes odoo.addons.encoach_api.utils.cache

View File

@@ -8,7 +8,10 @@
'external_dependencies': {
'python': ['PyJWT'],
},
'data': [],
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
],
'installable': True,
'license': 'LGPL-3',
}

View File

@@ -1,2 +1,5 @@
from . import base
from . import auth
from . import health
from . import openapi
from . import gdpr

View File

@@ -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 <access_token>``.
* ``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})
# ------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

@@ -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<conv>\w+):)?(?P<name>\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 ``<int:foo>`` 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")

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_purge_expired_jwt" model="ir.cron">
<field name="name">EnCoach: purge expired JWT refresh tokens</field>
<field name="model_id" ref="model_encoach_jwt_token"/>
<field name="state">code</field>
<field name="code">model.purge_expired()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import jwt_token
from . import gdpr_erasure

View File

@@ -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()

View File

@@ -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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_jwt_token_user encoach.jwt.token user model_encoach_jwt_token base.group_user 1 0 0 0
3 access_encoach_jwt_token_admin encoach.jwt.token admin model_encoach_jwt_token base.group_system 1 1 1 1
4 access_gdpr_erasure_admin encoach.gdpr.erasure.request admin model_encoach_gdpr_erasure_request base.group_system 1 1 1 1

View File

@@ -0,0 +1 @@
from . import test_health

View File

@@ -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]}",
)

View File

@@ -0,0 +1 @@
from . import cache # noqa: F401

View File

@@ -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

View File

@@ -7,4 +7,4 @@ from . import exam_schedules
from . import rubrics
from . import approval_workflows
from . import entities
from . import exam_session
from . import review_workflow

View File

@@ -256,36 +256,46 @@ class ApprovalWorkflowController(http.Controller):
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)
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
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(),
})
# Move to next stage or finalize.
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
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'})
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})
@@ -297,19 +307,26 @@ class ApprovalWorkflowController(http.Controller):
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)
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'})
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:

View File

@@ -1,316 +0,0 @@
import json
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 _question_to_student_dict(q):
return {
'id': q.id,
'skill': q.skill or '',
'question_type': q.question_type or '',
'stem': q.stem or '',
'options': json.loads(q.options) if q.options else [],
'marks': q.marks,
'difficulty': q.difficulty or '',
'source_type': q.source_type or '',
'source_id': q.source_id or 0,
}
class ExamSessionController(http.Controller):
@http.route('/api/exam/<int:exam_id>/session', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_session(self, exam_id, **kw):
try:
Exam = request.env['encoach.exam.custom'].sudo()
exam = Exam.browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam.id),
('status', 'in', ['in_progress', 'scoring']),
], limit=1, order='id desc')
if not attempt:
attempt = Attempt.create({
'student_id': uid,
'exam_id': exam.id,
'status': 'in_progress',
'entity_id': exam.entity_id.id if exam.entity_id else False,
})
Answer = request.env['encoach.student.answer'].sudo()
saved_answers = {}
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
saved_answers[ans.question_id.id] = ans.answer or ''
sections = []
for sec in exam.section_ids.sorted('sequence'):
questions = []
for q in sec.question_ids:
q_dict = _question_to_student_dict(q)
q_dict['saved_answer'] = saved_answers.get(q.id, '')
questions.append(q_dict)
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': str(attempt.started_at) if attempt.started_at else None,
'sections': sections,
})
except Exception as e:
_logger.exception('get_session failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/autosave', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def autosave(self, exam_id, **kw):
try:
body = _get_json_body()
attempt_id = body.get('attempt_id')
raw_answers = body.get('answers', [])
if not attempt_id:
return _error_response('attempt_id is required', 400)
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.browse(int(attempt_id))
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
saved = 0
for q_id, answer_val in answer_pairs:
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
if existing:
existing.write({'answer': str(answer_val)})
else:
Answer.create({
'attempt_id': attempt.id,
'question_id': q_id,
'answer': str(answer_val),
})
saved += 1
return _json_response({'saved': saved})
except Exception as e:
_logger.exception('autosave failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def submit_exam(self, exam_id, **kw):
try:
body = _get_json_body()
attempt_id = body.get('attempt_id')
raw_answers = body.get('answers', [])
if not attempt_id:
return _error_response('attempt_id is required', 400)
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.browse(int(attempt_id))
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
Question = request.env['encoach.question'].sudo()
total_score = 0.0
max_score = 0.0
for q_id, answer_val in answer_pairs:
q = Question.browse(q_id)
if not q.exists():
continue
is_correct = False
score = 0.0
correct = (q.correct_answer or '').strip().lower()
given = str(answer_val).strip().lower()
if correct and given:
is_correct = correct == given
score = q.marks if is_correct else 0.0
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
vals = {
'answer': str(answer_val),
'score': score,
'is_correct': is_correct,
}
if existing:
existing.write(vals)
else:
vals.update({
'attempt_id': attempt.id,
'question_id': q_id,
})
Answer.create(vals)
total_score += score
max_score += q.marks
from odoo.fields import Datetime
attempt.write({
'status': 'completed',
'finished_at': Datetime.now(),
'total_score': total_score,
'max_score': max_score,
})
return _json_response({
'attempt_id': attempt.id,
'status': 'completed',
'total_score': total_score,
'max_score': max_score,
'percentage': round(total_score / max_score * 100, 1) if max_score else 0,
'results_available': True,
})
except Exception as e:
_logger.exception('submit_exam failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_status(self, exam_id, **kw):
try:
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam_id),
], limit=1, order='id desc')
if not attempt:
return _error_response('No attempt found', 404)
scores_available = attempt.status == 'completed' and attempt.max_score > 0
return _json_response({
'status': attempt.status,
'scores_available': scores_available,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
})
except Exception as e:
_logger.exception('get_status failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/results', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_results(self, exam_id, **kw):
try:
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam_id),
('status', '=', 'completed'),
], limit=1, order='id desc')
if not attempt:
return _error_response('No completed attempt found', 404)
Answer = request.env['encoach.student.answer'].sudo()
answers = []
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
answers.append({
'question_id': ans.question_id.id,
'answer': ans.answer or '',
'score': ans.score,
'is_correct': ans.is_correct,
'feedback': ans.feedback or '',
})
Exam = request.env['encoach.exam.custom'].sudo()
exam = Exam.browse(exam_id)
return _json_response({
'attempt_id': attempt.id,
'exam_id': exam_id,
'exam_title': exam.title if exam.exists() else '',
'status': attempt.status,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
'started_at': str(attempt.started_at) if attempt.started_at else None,
'finished_at': str(attempt.finished_at) if attempt.finished_at else None,
'answers': answers,
})
except Exception as e:
_logger.exception('get_results failed')
return _error_response(str(e), 500)
@staticmethod
def _normalize_answers(raw):
"""Accept answers as list of {question_id, answer} or dict {qid: answer}."""
if isinstance(raw, list):
pairs = []
for item in raw:
if isinstance(item, dict):
qid = item.get('question_id') or item.get('qid')
ans = item.get('answer', '')
if qid:
pairs.append((int(qid), ans))
return pairs
if isinstance(raw, dict):
return [(int(k), v) for k, v in raw.items()]
return []

View File

@@ -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/<exam_id>`` — detail with per-question quality data
* ``POST /api/exam/review/<exam_id>/approve`` — publish (optional notes)
* ``POST /api/exam/review/<exam_id>/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/<exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>', 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/<exam_id>/approve
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/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/<exam_id>/reject
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/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)

View File

@@ -11,5 +11,4 @@ from . import exam_custom_section
from . import exam_assignment
from . import exam_schedule
from . import exam_structure
from . import student_attempt
from . import approval

View File

@@ -39,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/<id>/(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.',
)

View File

@@ -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

View File

@@ -1,52 +0,0 @@
from odoo import models, fields
class EncoachStudentAttempt(models.Model):
_name = 'encoach.student.attempt'
_description = 'Student Exam Attempt'
_order = 'id desc'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade')
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
status = fields.Selection([
('in_progress', 'In Progress'),
('scoring', 'Scoring'),
('completed', 'Completed'),
('abandoned', 'Abandoned'),
], default='in_progress', required=True)
started_at = fields.Datetime(default=fields.Datetime.now)
finished_at = fields.Datetime()
overall_band = fields.Float()
cefr_level = fields.Char(size=10)
listening_band = fields.Float()
reading_band = fields.Float()
writing_band = fields.Float()
speaking_band = fields.Float()
total_score = fields.Float()
max_score = fields.Float()
class EncoachStudentAnswer(models.Model):
_name = 'encoach.student.answer'
_description = 'Student Answer'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade')
answer = fields.Text()
score = fields.Float()
is_correct = fields.Boolean()
feedback = fields.Text()
class EncoachStudentScore(models.Model):
_name = 'encoach.student.score'
_description = 'Student Skill Score'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
skill = fields.Char(size=50, required=True)
band_score = fields.Float()
raw_score = fields.Float()
max_score = fields.Float()
cefr_level = fields.Char(size=10)
entity_id = fields.Many2one('encoach.entity', ondelete='set null')

View File

@@ -12,9 +12,6 @@ access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_e
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_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_encoach_exam_assignment_user encoach.exam.assignment.user model_encoach_exam_assignment base.group_user 1 1 1 1
13 access_encoach_exam_schedule_user encoach.exam.schedule.user model_encoach_exam_schedule base.group_user 1 1 1 1
14 access_encoach_exam_structure_user encoach.exam.structure.user model_encoach_exam_structure base.group_user 1 1 1 1
access_encoach_student_attempt_user encoach.student.attempt.user model_encoach_student_attempt base.group_user 1 1 1 1
access_encoach_student_answer_user encoach.student.answer.user model_encoach_student_answer base.group_user 1 1 1 1
access_encoach_student_score_user encoach.student.score.user model_encoach_student_score base.group_user 1 1 1 1
15 access_encoach_approval_workflow_user encoach.approval.workflow.user model_encoach_approval_workflow base.group_user 1 1 1 1
16 access_encoach_approval_stage_user encoach.approval.stage.user model_encoach_approval_stage base.group_user 1 1 1 1
17 access_encoach_approval_request_user encoach.approval.request.user model_encoach_approval_request base.group_user 1 1 1 1

View File

@@ -20,6 +20,7 @@
'openeducat_library',
'encoach_core',
'encoach_api',
'encoach_ai',
'encoach_taxonomy',
'encoach_resources',
],

View File

@@ -22,6 +22,7 @@ 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

View File

@@ -36,7 +36,7 @@ class AttendanceController(http.Controller):
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({'data': data})
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_attendance')
return _error_response(str(e), 500)

View File

@@ -35,7 +35,7 @@ class GradesController(http.Controller):
'date': str(r.create_date.date()) if r.create_date else '',
'type': r.state or 'graded',
})
return _json_response({'data': data})
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_grades')
return _error_response(str(e), 500)

View File

@@ -116,10 +116,36 @@ class PaymentRecordsController(http.Controller):
methods=['GET'], csrf=False)
@jwt_required
def list_paymob_orders(self, **kw):
"""Paymob integration is not yet wired — return empty list.
This endpoint exists so the UI renders cleanly instead of calling a
404-returning route."""
return _json_response({
'data': [], 'items': [], 'total': 0,
'message': 'Paymob integration not configured.',
})
"""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)

View File

@@ -0,0 +1,351 @@
"""Paymob checkout initiator + HMAC-verified webhook.
This module talks to the `Paymob Accept <https://accept.paymob.com/>`_ 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)

View File

@@ -23,35 +23,24 @@ 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')
# CEFR ordering + band ranges used when an attempt didn't store cefr_level.
_CEFR_BY_BAND = [
(9.0, 'C2'), (7.0, 'C1'), (5.5, 'B2'),
(4.0, 'B1'), (3.0, 'A2'), (2.0, 'A1'), (0.0, 'Pre-A1'),
]
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):
if band is None:
"""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
for threshold, label in _CEFR_BY_BAND:
if band >= threshold:
return label
return 'Pre-A1'
def _normalize_cefr(label):
if not label:
return None
s = str(label).strip().lower().replace('-', '_')
return {
'pre_a1': 'Pre-A1', 'a1': 'A1', 'a2': 'A2',
'b1': 'B1', 'b2': 'B2', 'c1': 'C1', 'c2': 'C2',
}.get(s, label)
return _normalize_cefr(code) or code
def _duration_minutes(att):
@@ -232,6 +221,23 @@ class ReportsController(http.Controller):
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)
@@ -242,55 +248,88 @@ class ReportsController(http.Controller):
if threshold > 0:
domain.append(('overall_band', '>=', threshold / 10.0))
attempts = Att.search(domain)
skill_totals = {k: [0.0, 0] for k in
('reading', 'listening', 'writing', 'speaking')}
for att in attempts:
for skill, field in (('reading', att.reading_band),
('listening', att.listening_band),
('writing', att.writing_band),
('speaking', att.speaking_band)):
if field is not None and field > 0:
skill_totals[skill][0] += field
skill_totals[skill][1] += 1
# --- 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 = []
for skill in ('Reading', 'Listening', 'Writing', 'Speaking'):
total, n = skill_totals[skill.lower()]
avg = round((total / n) * 10, 1) if n else 0
by_module.append({'module': skill, 'score': avg, 'n': n})
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
month_buckets = defaultdict(lambda: [0.0, 0])
for att in attempts:
ct = _attempt_completed_at(att) or att.started_at
if not ct or att.overall_band is None:
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
key = ct.strftime('%Y-%m')
month_buckets[key][0] += att.overall_band
month_buckets[key][1] += 1
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')
total, n = month_buckets.get(key, (0.0, 0))
avg = round((total / n) * 10, 1) if n else 0
trend.append({'month': ref.strftime('%b'), 'avg': avg,
'period': key, 'n': n})
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,
})
level_buckets = defaultdict(int)
for att in attempts:
label = _normalize_cefr(att.cefr_level) \
or _cefr_for_band(att.overall_band)
# --- 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] += 1
level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0)
palette = {
'Pre-A1': 'hsl(0, 0%, 55%)',
'A1': 'hsl(0, 72%, 51%)',
@@ -310,35 +349,39 @@ class ReportsController(http.Controller):
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
]
entity_buckets = defaultdict(lambda: [0.0, 0, 'Unassigned'])
for att in attempts:
if att.overall_band is None:
continue
eid = att.entity_id.id or 0
entity_buckets[eid][0] += att.overall_band
entity_buckets[eid][1] += 1
entity_buckets[eid][2] = att.entity_id.name or 'Unassigned'
# --- 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 eid, (total, n, name) in entity_buckets.items():
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 or None,
'entity_name': name,
'avg': round((total / n) * 10, 1) if n else 0,
'attempts': n,
'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'])
return _json_response({
total_considered = Att.search_count(domain)
payload = {
'by_module': by_module,
'trend': trend,
'distribution': distribution,
'comparison': comparison,
'meta': {
'attempts_considered': len(attempts),
'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)

View File

@@ -37,7 +37,7 @@ class TimetableController(http.Controller):
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({'data': data})
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_timetable')
return _error_response(str(e), 500)

View File

@@ -192,7 +192,7 @@ class UsersRolesController(http.Controller):
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({'data': items, 'total': total, 'page': page, 'size': limit})
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@@ -299,7 +299,7 @@ class UsersRolesController(http.Controller):
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({'data': items, 'total': len(items)})
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as e:
return _error_response(str(e), 500)
@@ -417,7 +417,7 @@ class UsersRolesController(http.Controller):
'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids],
'effective_permission_count': len(all_perms),
})
return _json_response({'data': items, 'total': total, 'page': page, 'size': limit})
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)

View File

@@ -9,3 +9,4 @@ from . import course_ext
from . import asset
from . import ticket
from . import training
from . import paymob_order

View File

@@ -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,
})

View File

@@ -1,4 +1,8 @@
from odoo import fields, models
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class EncoachTicket(models.Model):
@@ -35,3 +39,129 @@ class EncoachTicket(models.Model):
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

View File

@@ -23,3 +23,5 @@ access_encoach_vocab_item_all,encoach.vocab.item.all,model_encoach_vocab_item,ba
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
23 access_encoach_vocab_progress_all encoach.vocab.progress.all model_encoach_vocab_progress base.group_user 1 1 1 1
24 access_encoach_grammar_rule_all encoach.grammar.rule.all model_encoach_grammar_rule base.group_user 1 1 1 1
25 access_encoach_grammar_progress_all encoach.grammar.progress.all model_encoach_grammar_progress base.group_user 1 1 1 1
26 access_encoach_paymob_order_user encoach.paymob.order.user model_encoach_paymob_order base.group_user 1 0 1 0
27 access_encoach_paymob_order_admin encoach.paymob.order.admin model_encoach_paymob_order base.group_system 1 1 1 1

View File

@@ -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',

View File

@@ -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."""

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -102,7 +102,8 @@ class EncoachTaxonomyController(http.Controller):
try:
recs = request.env['encoach.subject'].sudo().search([])
items = [_subject_dict(s) for s in recs]
return _json_response({'data': items, 'subjects': items})
return _json_response({'items': items, 'data': items,
'subjects': items, 'total': len(items)})
except Exception as e:
_logger.exception('list_subjects')
return _error_response(str(e), 500)
@@ -205,7 +206,9 @@ class EncoachTaxonomyController(http.Controller):
if kw.get('subject_id'):
domain_filter.append(('subject_id', '=', int(kw['subject_id'])))
recs = request.env['encoach.domain'].sudo().search(domain_filter)
return _json_response({'data': [_domain_dict(d) for d in recs]})
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)
@@ -290,7 +293,9 @@ class EncoachTaxonomyController(http.Controller):
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)
return _json_response({'data': [_topic_dict(t) for t in recs]})
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)

View File

@@ -28,9 +28,17 @@ class EncoachEmbedding(models.Model):
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
@@ -82,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():
@@ -117,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

View File

@@ -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",

View File

@@ -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,6 +49,7 @@ MODEL_CONFIG = [
'text_field': 'brief',
'description_field': 'generated_content',
'metadata_fields': ['course_type', 'status'],
'taxonomy_field': 'course_type',
},
{
'model': 'encoach.chapter.material',
@@ -46,10 +57,21 @@ MODEL_CONFIG = [
'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 = []
@@ -69,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

View File

@@ -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:

View File

@@ -1319,3 +1319,133 @@ Network tab showed `/api/reports/filters 200`, `/api/reports/record?size=100 200
- **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 `<ThumbsUp/>/<ThumbsDown/>` 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 = <merchant API key>
encoach.paymob.hmac_secret = <from Paymob dashboard>
encoach.paymob.integration_id = <default integration (card)>
encoach.paymob.iframe_id = <hosted 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.

26
docs/adr/0000-template.md Normal file
View File

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

View File

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

View File

@@ -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`.

View File

@@ -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<T>` 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.

View File

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

22
docs/adr/README.md Normal file
View File

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

View File

@@ -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 });
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"] },
},
],
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 48 KiB

12
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="EnCoach">
<defs>
<linearGradient id="eg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4f46e5"/>
<stop offset="50%" stop-color="#7c3aed"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#eg)"/>
<path d="M20 20h22a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H20a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2Z" fill="#ffffff"/>
<circle cx="48" cy="48" r="5" fill="#facc15" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

View File

@@ -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,146 +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 TeacherLibrary from "@/pages/teacher/TeacherLibrary";
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 (
@@ -158,14 +180,30 @@ function StudentSubscriptionPlaceholder() {
);
}
function RouteFallback() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const App = () => (
<ErrorBoundary>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="encoach-theme"
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<AuthProvider>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Auth routes */}
<Route path="/login" element={<Login />} />
@@ -200,6 +238,7 @@ const App = () => (
<Route path="/student/attendance" element={<StudentAttendance />} />
<Route path="/student/timetable" element={<StudentTimetable />} />
<Route path="/student/profile" element={<StudentProfile />} />
<Route path="/student/privacy" element={<PrivacyCenter />} />
<Route path="/student/subjects" element={<SubjectSelection />} />
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
@@ -241,6 +280,7 @@ const App = () => (
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} />
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
</Route>
@@ -263,6 +303,7 @@ const App = () => (
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */}
<Route path="/admin/assignments" element={<AssignmentsPage />} />
<Route path="/admin/examsList" element={<ExamsListPage />} />
@@ -285,13 +326,16 @@ const App = () => (
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
<Route path="/admin/tickets" element={<TicketsPage />} />
<Route path="/admin/settings-platform" element={<SettingsPage />} />
<Route path="/admin/exam" element={<ExamPage />} />
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
<Route path="/admin/exam/review-queue" element={<ExamReviewQueue />} />
<Route path="/admin/exam/review/:examId" element={<ExamReviewDetail />} />
<Route path="/admin/ai/prompts" element={<AIPromptEditor />} />
<Route path="/admin/ai/feedback" element={<AIFeedbackTriage />} />
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
<Route path="/admin/resources" element={<ResourceManager />} />
{/* Institutional LMS pages */}
@@ -340,10 +384,12 @@ const App = () => (
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</AuthProvider>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
</ErrorBoundary>
);

View File

@@ -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 (
<div className={cn("flex items-center gap-1", className)}>
<Button
variant={myRating === "up" ? "default" : "outline"}
size={btnSize}
onClick={submitUp}
disabled={submit.isPending}
aria-label="Thumbs up"
>
<ThumbsUp className={iconCls} />
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
</Button>
<Button
variant={myRating === "down" ? "default" : "outline"}
size={btnSize}
onClick={() => setDownOpen(true)}
disabled={submit.isPending}
aria-label="Thumbs down"
>
<ThumbsDown className={iconCls} />
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
</Button>
<Dialog open={downOpen} onOpenChange={setDownOpen}>
<DialogContent
className="max-w-md"
description="Tell us what was wrong so we can improve this AI output."
>
<DialogHeader>
<DialogTitle>What went wrong?</DialogTitle>
</DialogHeader>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setDownOpen(false)}>
Cancel
</Button>
<Button onClick={submitDown} disabled={submit.isPending}>
Submit feedback
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AIFeedbackButtons;

View File

@@ -9,6 +9,8 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { NavLink } from "@/components/NavLink";
import { useAuth } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar";
import { usePermissions } from "@/hooks/usePermissions";
@@ -29,7 +31,7 @@ import {
CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow,
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
Library, Activity, Warehouse, UserCog,
Library, Activity, Warehouse, UserCog, Sparkles,
} from "lucide-react";
import React from "react";
@@ -56,6 +58,9 @@ const academicItems: NavItem[] = [
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
{ title: "Review Queue", url: "/admin/exam/review-queue", icon: Sparkles },
{ title: "AI Prompts", url: "/admin/ai/prompts", icon: Wand2 },
{ title: "AI Feedback", url: "/admin/ai/feedback", icon: Sparkles },
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
];
@@ -150,6 +155,8 @@ const routeLabels: Record<string, string> = {
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
"review-queue": "Review Queue", review: "Review", prompts: "AI Prompts", ai: "AI",
feedback: "Feedback",
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
classrooms: "Classrooms", "student-performance": "Student Performance",
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
@@ -218,6 +225,8 @@ export default function AdminLmsLayout() {
</div>
<AiSearchBar />
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" />
@@ -282,12 +291,18 @@ function AdminSidebar() {
<Sidebar collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
{!collapsed && (
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span>Coach</span>
</span>
{collapsed ? (
<img
src="/logo.png"
alt="EnCoach"
className="h-9 w-9 shrink-0 rounded-lg object-contain"
/>
) : (
<img
src="/logo.png"
alt="EnCoach — Unlock your potential with AI powered platform"
className="h-14 w-auto shrink-0 object-contain"
/>
)}
</div>
</SidebarHeader>

View File

@@ -0,0 +1,43 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
export function LanguageToggle() {
const { i18n } = useTranslation();
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Change language">
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Language</DropdownMenuLabel>
{SUPPORTED_LANGS.map((lang) => (
<DropdownMenuCheckboxItem
key={lang.code}
checked={current === lang.code}
onCheckedChange={(checked) => {
if (checked) void i18n.changeLanguage(lang.code);
}}
>
{lang.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LanguageToggle;

View File

@@ -8,6 +8,8 @@ import {
import { NavLink } from "@/components/NavLink";
import { useAuth, UserRole } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
@@ -83,11 +85,16 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<Sidebar collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span>Coach</span>
</span>
<img
src="/logo.png"
alt="EnCoach"
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
/>
<img
src="/logo.png"
alt="EnCoach — Unlock your potential with AI powered platform"
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
/>
</div>
</SidebarHeader>
<SidebarSeparator />
@@ -115,6 +122,8 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
</div>
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -0,0 +1,68 @@
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
/**
* Light/dark/system theme toggle.
*
* We intentionally render a neutral placeholder until `next-themes` has
* hydrated — otherwise the first client render disagrees with the server-less
* pre-render and the icon briefly flashes the wrong state.
*/
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const isDark = mounted && (resolvedTheme ?? theme) === "dark";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
className="text-muted-foreground hover:text-foreground"
>
{mounted ? (
isDark ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)
) : (
<Sun className="h-4 w-4 opacity-0" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" /> Light
{theme === "light" && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" /> Dark
{theme === "dark" && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" /> System
{theme === "system" && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default ThemeToggle;

View File

@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
function hasAlertDialogDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === AlertDialogPrimitive.Description ||
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasAlertDialogDescription(nested)) {
found = true;
}
});
return found;
}
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
typeof AlertDialogPrimitive.Content
> & {
/**
* Optional accessible description rendered visually-hidden. When omitted and
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
* emitted so screen readers still have something to announce.
*/
description?: React.ReactNode;
};
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContentProps
>(({ className, children, description, ...props }, ref) => {
const needsFallback =
!props["aria-describedby"] &&
description === undefined &&
!hasAlertDialogDescription(children);
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{description !== undefined && (
<AlertDialogPrimitive.Description className="sr-only">
{description}
</AlertDialogPrimitive.Description>
)}
{needsFallback && (
<AlertDialogPrimitive.Description className="sr-only">
Alert dialog content
</AlertDialogPrimitive.Description>
)}
{children}
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
);
});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@@ -27,29 +27,86 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/**
* Walks `children` looking for any element whose component is
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
* Radix emits an a11y warning when a `DialogContent` has neither a
* `Description` nor an explicit `aria-describedby`, so we use this to decide
* whether to inject a visually-hidden fallback description.
*/
function hasDialogDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === DialogPrimitive.Description ||
(type && type.displayName === DialogPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasDialogDescription(nested)) {
found = true;
}
});
return found;
}
type DialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> & {
/**
* Optional accessible description. When supplied, a visually-hidden
* `DialogDescription` is rendered so screen readers announce it without
* affecting layout. Prefer this for simple dialogs; for richer descriptions
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
*/
description?: React.ReactNode;
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
aria-describedby={props["aria-describedby"] ?? undefined}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContentProps
>(({ className, children, description, ...props }, ref) => {
const hasExplicitDescribedBy = !!props["aria-describedby"];
const needsFallback =
!hasExplicitDescribedBy &&
description === undefined &&
!hasDialogDescription(children);
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{description !== undefined && (
<DialogPrimitive.Description className="sr-only">
{description}
</DialogPrimitive.Description>
)}
{needsFallback && (
<DialogPrimitive.Description className="sr-only">
Dialog content
</DialogPrimitive.Description>
)}
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@@ -49,21 +49,61 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
/**
* Optional accessible description rendered visually-hidden. Falls back to a
* generic string when no `SheetDescription` is found in the subtree, which
* keeps Radix happy without forcing every call-site to add one.
*/
description?: React.ReactNode;
}
function hasSheetDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === SheetPrimitive.Description ||
(type && type.displayName === SheetPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasSheetDescription(nested)) {
found = true;
}
});
return found;
}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
({ side = "right", className, children, description, ...props }, ref) => {
const needsFallback =
!props["aria-describedby"] &&
description === undefined &&
!hasSheetDescription(children);
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{description !== undefined && (
<SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
)}
{needsFallback && (
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
)}
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
},
);
SheetContent.displayName = SheetPrimitive.Content.displayName;

View File

@@ -4,244 +4,246 @@ export const queryKeys = {
},
users: {
all: ["users"] as const,
list: (params: Record<string, unknown>) => ["users", "list", params] as const,
list: (params: object) => ["users", "list", params] as const,
detail: (id: number) => ["users", id] as const,
},
entities: {
all: ["entities"] as const,
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const,
list: (params?: object) => ["entities", "list", params] as const,
detail: (id: number) => ["entities", id] as const,
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
},
exams: {
all: ["exams"] as const,
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const,
list: (module: string, params?: object) => ["exams", module, params] as const,
detail: (module: string, id: number) => ["exams", module, id] as const,
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
structures: (params?: object) => ["exams", "structures", params] as const,
avatars: ["exams", "avatars"] as const,
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
},
assignments: {
all: ["assignments"] as const,
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const,
list: (params?: object) => ["assignments", "list", params] as const,
detail: (id: number) => ["assignments", id] as const,
},
classrooms: {
all: ["classrooms"] as const,
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const,
list: (params?: object) => ["classrooms", "list", params] as const,
detail: (id: number) => ["classrooms", id] as const,
},
stats: {
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
sessions: (params?: object) => ["stats", "sessions", params] as const,
stats: (params?: object) => ["stats", "stats", params] as const,
statistical: (params?: object) => ["stats", "statistical", params] as const,
performance: (params?: object) => ["stats", "performance", params] as const,
},
training: {
all: ["training"] as const,
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const,
tips: (params?: object) => ["training", "tips", params] as const,
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
},
subscriptions: {
packages: ["subscriptions", "packages"] as const,
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
payments: (params?: object) => ["subscriptions", "payments", params] as const,
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
},
tickets: {
all: ["tickets"] as const,
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const,
list: (params?: object) => ["tickets", "list", params] as const,
assigned: ["tickets", "assigned"] as const,
},
approvals: {
all: ["approvals"] as const,
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
list: (params?: object) => ["approvals", "list", params] as const,
},
taxonomy: {
subjects: ["taxonomy", "subjects"] as const,
subject: (id: number) => ["taxonomy", "subjects", id] as const,
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
domains: (params?: object) => ["taxonomy", "domains", params] as const,
topics: (params?: object) => ["taxonomy", "topics", params] as const,
},
adaptive: {
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const,
proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
summary: ["adaptive", "proficiency", "summary"] as const,
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const,
plan: (params?: object) => ["adaptive", "plan", params] as const,
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
},
resources: {
all: ["resources"] as const,
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
list: (params?: object) => ["resources", "list", params] as const,
},
lms: {
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
courses: (params?: object) => ["lms", "courses", params] as const,
course: (id: number) => ["lms", "courses", id] as const,
myCourses: ["lms", "my-courses"] as const,
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
students: (params?: object) => ["lms", "students", params] as const,
teachers: (params?: object) => ["lms", "teachers", params] as const,
batches: (params?: object) => ["lms", "batches", params] as const,
batch: (id: number) => ["lms", "batches", id] as const,
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
timetable: (params?: object) => ["lms", "timetable", params] as const,
attendance: (params?: object) => ["lms", "attendance", params] as const,
grades: (params?: object) => ["lms", "grades", params] as const,
},
analytics: {
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
student: (params?: object) => ["analytics", "student", params] as const,
class: (params?: object) => ["analytics", "class", params] as const,
subject: (params?: object) => ["analytics", "subject", params] as const,
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
alerts: ["analytics", "alerts"] as const,
},
academicYears: {
all: ["academic-years"] as const,
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
list: (params?: object) => ["academic-years", "list", params] as const,
detail: (id: number) => ["academic-years", id] as const,
},
academicTerms: {
all: ["academic-terms"] as const,
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
list: (params?: object) => ["academic-terms", "list", params] as const,
detail: (id: number) => ["academic-terms", id] as const,
},
departments: {
all: ["departments"] as const,
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
list: (params?: object) => ["departments", "list", params] as const,
detail: (id: number) => ["departments", id] as const,
},
admissionRegisters: {
all: ["admission-registers"] as const,
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
list: (params?: object) => ["admission-registers", "list", params] as const,
detail: (id: number) => ["admission-registers", id] as const,
},
admissions: {
all: ["admissions"] as const,
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
list: (params?: object) => ["admissions", "list", params] as const,
detail: (id: number) => ["admissions", id] as const,
},
instExamSessions: {
all: ["inst-exam-sessions"] as const,
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
detail: (id: number) => ["inst-exam-sessions", id] as const,
},
examTypes: {
all: ["exam-types"] as const,
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
list: (params?: object) => ["exam-types", "list", params] as const,
},
gradeConfigs: {
all: ["grade-configs"] as const,
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
list: (params?: object) => ["grade-configs", "list", params] as const,
},
resultTemplates: {
all: ["result-templates"] as const,
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
list: (params?: object) => ["result-templates", "list", params] as const,
},
marksheets: {
all: ["marksheets"] as const,
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
list: (params?: object) => ["marksheets", "list", params] as const,
detail: (id: number) => ["marksheets", id] as const,
},
courseAssignments: {
all: ["course-assignments"] as const,
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
list: (params?: object) => ["course-assignments", "list", params] as const,
detail: (id: number) => ["course-assignments", id] as const,
},
assignmentTypes: {
all: ["assignment-types"] as const,
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
list: (params?: object) => ["assignment-types", "list", params] as const,
},
subjectRegistrations: {
all: ["subject-registrations"] as const,
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
list: (params?: object) => ["subject-registrations", "list", params] as const,
available: (params?: object) => ["subject-registrations", "available", params] as const,
},
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
chapter: (id: number) => ["chapters", "detail", id] as const,
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
announcements: (params?: object) => ["announcements", params] as const,
messages: (params?: object) => ["messages", params] as const,
sentMessages: (params?: object) => ["messages", "sent", params] as const,
unreadMessages: ["messages", "unread-count"] as const,
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const,
notifications: (params?: object) => ["notifications", params] as const,
unreadNotifications: ["notifications", "unread-count"] as const,
notificationRules: ["notification-rules"] as const,
notificationPreferences: ["notification-preferences"] as const,
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const,
faqItems: (params?: object) => ["faq", "items", params] as const,
studentLeaves: {
all: ["student-leaves"] as const,
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const,
list: (params?: object) => ["student-leaves", "list", params] as const,
},
studentLeaveTypes: {
all: ["student-leave-types"] as const,
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const,
list: (params?: object) => ["student-leave-types", "list", params] as const,
},
feesPlans: {
all: ["fees-plans"] as const,
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const,
list: (params?: object) => ["fees-plans", "list", params] as const,
detail: (id: number) => ["fees-plans", id] as const,
},
studentFees: {
all: ["student-fees"] as const,
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const,
list: (params?: object) => ["student-fees", "list", params] as const,
},
feesTerms: {
all: ["fees-terms"] as const,
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const,
list: (params?: object) => ["fees-terms", "list", params] as const,
},
lessons: {
all: ["lessons"] as const,
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
list: (params?: object) => ["lessons", "list", params] as const,
},
gradebooks: {
all: ["gradebooks"] as const,
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
list: (params?: object) => ["gradebooks", "list", params] as const,
},
gradebookLines: {
all: ["gradebook-lines"] as const,
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const,
list: (params?: object) => ["gradebook-lines", "list", params] as const,
},
gradingAssignments: {
all: ["grading-assignments"] as const,
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const,
list: (params?: object) => ["grading-assignments", "list", params] as const,
},
studentProgress: {
all: ["student-progress"] as const,
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
list: (params?: object) => ["student-progress", "list", params] as const,
detail: (id: number) => ["student-progress", "detail", id] as const,
},
libraryMedia: {
all: ["library-media"] as const,
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const,
list: (params?: object) => ["library-media", "list", params] as const,
},
libraryMovements: {
all: ["library-movements"] as const,
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const,
list: (params?: object) => ["library-movements", "list", params] as const,
},
libraryCards: {
all: ["library-cards"] as const,
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const,
list: (params?: object) => ["library-cards", "list", params] as const,
},
activities: {
all: ["activities"] as const,
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
list: (params?: object) => ["activities", "list", params] as const,
},
activityTypes: {
all: ["activity-types"] as const,
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const,
list: (params?: object) => ["activity-types", "list", params] as const,
},
facilities: {
all: ["facilities"] as const,
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
list: (params?: object) => ["facilities", "list", params] as const,
},
assets: {
all: ["assets"] as const,
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
list: (params?: object) => ["assets", "list", params] as const,
},
signup: {
goals: ["signup", "goals"] as const,
@@ -258,7 +260,7 @@ export const queryKeys = {
},
ieltsExam: {
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
contentPool: (examId: number, filters?: Record<string, unknown>) => ["ielts-exam", examId, "content-pool", filters] as const,
contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
},
examSession: {
@@ -266,7 +268,7 @@ export const queryKeys = {
status: (examId: number) => ["exam-session", examId, "status"] as const,
},
grading: {
queue: (params?: Record<string, unknown>) => ["grading", "queue", params] as const,
queue: (params?: object) => ["grading", "queue", params] as const,
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
},
@@ -282,11 +284,11 @@ export const queryKeys = {
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
},
entityOnboarding: {
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
},
adaptiveEngine: {
dashboard: ["adaptive-engine", "dashboard"] as const,
students: (params?: Record<string, unknown>) => ["adaptive-engine", "students", params] as const,
students: (params?: object) => ["adaptive-engine", "students", params] as const,
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
settings: ["adaptive-engine", "settings"] as const,
@@ -299,7 +301,7 @@ export const queryKeys = {
current: ["branding", "current"] as const,
},
scoreRelease: {
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
pending: (params?: object) => ["score-release", "pending", params] as const,
},
verification: {
verify: (hash: string) => ["verification", hash] as const,

View File

@@ -0,0 +1,70 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiFeedbackService } from "@/services/ai-feedback.service";
import type {
AIFeedbackStatus,
AIFeedbackSubjectType,
AIFeedbackSubmitInput,
} from "@/types/ai-feedback";
const KEY_ROOT = ["ai", "feedback"] as const;
export function useAIFeedbackSummary(
subjectType: AIFeedbackSubjectType | undefined,
subjectId: number | undefined,
) {
return useQuery({
enabled: !!subjectType && !!subjectId,
queryKey: [...KEY_ROOT, "summary", subjectType, subjectId] as const,
queryFn: () =>
aiFeedbackService.summary(
subjectType as AIFeedbackSubjectType,
subjectId as number,
),
});
}
export function useSubmitAIFeedback() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: AIFeedbackSubmitInput) => aiFeedbackService.submit(input),
onSuccess: (_res, vars) => {
qc.invalidateQueries({
queryKey: [...KEY_ROOT, "summary", vars.subject_type, vars.subject_id],
});
qc.invalidateQueries({ queryKey: [...KEY_ROOT, "list"] });
},
});
}
export function useAIFeedbackList(
params: {
status?: AIFeedbackStatus;
rating?: "up" | "down";
subject_type?: AIFeedbackSubjectType;
prompt_key?: string;
page?: number;
size?: number;
} = {},
) {
return useQuery({
queryKey: [...KEY_ROOT, "list", params] as const,
queryFn: () => aiFeedbackService.list(params),
});
}
export function useResolveAIFeedback() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
status,
notes,
}: {
id: number;
status: Exclude<AIFeedbackStatus, "open">;
notes?: string;
}) => aiFeedbackService.resolve(id, status, notes),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}

View File

@@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiPromptService } from "@/services/ai-prompt.service";
import type { AIPromptCreateInput } from "@/types/ai-prompt";
const KEY_ROOT = ["ai", "prompts"] as const;
export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
return useQuery({
queryKey: [...KEY_ROOT, "keys", params] as const,
queryFn: () => aiPromptService.listKeys(params),
});
}
export function useAIPromptVersions(key: string | undefined) {
return useQuery({
enabled: !!key,
queryKey: [...KEY_ROOT, "versions", key] as const,
queryFn: () => aiPromptService.listVersions(key as string),
});
}
export function useAIPrompt(id: number | undefined) {
return useQuery({
enabled: !!id,
queryKey: [...KEY_ROOT, "detail", id] as const,
queryFn: () => aiPromptService.get(id as number),
});
}
export function useCreateAIPrompt() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}
export function useActivateAIPrompt() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => aiPromptService.activate(id),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}
export function useRenderAIPrompt() {
return useMutation({
mutationFn: ({ id, variables }: { id: number; variables: Record<string, string> }) =>
aiPromptService.render(id, variables),
});
}

View File

@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
examReviewService,
type ExamReviewQueueParams,
} from "@/services/exam-review.service";
import { queryKeys } from "./keys";
export function useExamReviewQueue(params: ExamReviewQueueParams = {}) {
return useQuery({
queryKey: queryKeys.exams.reviewQueue(params),
queryFn: () => examReviewService.queue(params),
});
}
export function useExamReviewDetail(examId: number | undefined) {
return useQuery({
enabled: !!examId,
queryKey: queryKeys.exams.reviewDetail(examId ?? -1),
queryFn: () => examReviewService.detail(examId as number),
});
}
export function useApproveExamReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, notes }: { examId: number; notes?: string }) =>
examReviewService.approve(examId, notes),
onSuccess: (_data, { examId }) => {
qc.invalidateQueries({ queryKey: ["exams", "review"] });
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
},
});
}
export function useRejectExamReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, notes }: { examId: number; notes: string }) =>
examReviewService.reject(examId, notes),
onSuccess: (_data, { examId }) => {
qc.invalidateQueries({ queryKey: ["exams", "review"] });
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
},
});
}

View File

@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
});
}
export function useIeltsValidation(examId: number) {
export function useIeltsExamValidation(examId: number) {
return useQuery({
queryKey: queryKeys.ieltsExam.validation(examId),
queryFn: () => ieltsExamService.validate(examId),

View File

@@ -0,0 +1,67 @@
/**
* EnCoach i18n bootstrap.
*
* We keep the translation strings in TypeScript modules (one per locale) so
* they go through type-checking and tree-shaking, and so non-translators
* can't ship a broken JSON file that blocks the build.
*
* Language detection order:
* 1. localStorage key ``encoach-lang`` (explicit user pick)
* 2. ``navigator.language``
* 3. fallback ``en``
*
* RTL handling: whenever the active language switches to one of RTL_LANGS,
* we flip ``document.documentElement.dir`` so Tailwind's RTL utilities +
* Radix primitives pick up the change automatically.
*/
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import ar from "./locales/ar";
import en from "./locales/en";
const RTL_LANGS = new Set(["ar", "he", "fa", "ur"]);
export const SUPPORTED_LANGS = [
{ code: "en", label: "English" },
{ code: "ar", label: "العربية" },
] as const;
export type SupportedLang = (typeof SUPPORTED_LANGS)[number]["code"];
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
ar: { translation: ar },
},
fallbackLng: "en",
supportedLngs: SUPPORTED_LANGS.map((l) => l.code),
interpolation: { escapeValue: false },
detection: {
order: ["localStorage", "navigator", "htmlTag"],
caches: ["localStorage"],
lookupLocalStorage: "encoach-lang",
},
returnEmptyString: false,
});
export function applyDirectionForLang(lang: string) {
const base = lang.split("-")[0];
const dir = RTL_LANGS.has(base) ? "rtl" : "ltr";
if (typeof document !== "undefined") {
document.documentElement.dir = dir;
document.documentElement.lang = base;
}
}
if (typeof window !== "undefined") {
applyDirectionForLang(i18n.language || "en");
i18n.on("languageChanged", applyDirectionForLang);
}
export default i18n;

View File

@@ -0,0 +1,85 @@
import type { Translations } from "./en";
/** Arabic (RTL) translations. */
const ar: Translations = {
common: {
cancel: "إلغاء",
save: "حفظ",
delete: "حذف",
edit: "تعديل",
close: "إغلاق",
back: "رجوع",
next: "التالي",
search: "بحث",
loading: "جارٍ التحميل…",
error: "خطأ",
retry: "إعادة المحاولة",
yes: "نعم",
no: "لا",
actions: "إجراءات",
status: "الحالة",
settings: "الإعدادات",
signOut: "تسجيل الخروج",
},
auth: {
signIn: "تسجيل الدخول",
signInTitle: "تسجيل الدخول إلى EnCoach",
email: "البريد الإلكتروني",
password: "كلمة المرور",
forgotPassword: "هل نسيت كلمة المرور؟",
needAccount: "ليس لديك حساب؟",
signUp: "إنشاء حساب",
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
},
nav: {
dashboard: "لوحة التحكم",
courses: "الدورات",
students: "الطلاب",
teachers: "المعلمون",
exams: "الاختبارات",
reports: "التقارير",
settings: "الإعدادات",
profile: "الملف الشخصي",
privacy: "مركز الخصوصية",
reviewQueue: "قائمة المراجعة",
aiPrompts: "تعليمات الذكاء الاصطناعي",
aiFeedback: "ملاحظات الذكاء الاصطناعي",
},
privacy: {
title: "مركز الخصوصية",
description:
"إدارة بياناتك الشخصية لدى EnCoach وفقاً للائحة حماية البيانات العامة وما يماثلها.",
exportTitle: "تنزيل بياناتك",
exportDescription:
"سنقوم بتجميع ملفك الشخصي، عضويات الكيانات، محاولات الاختبارات، الإجابات، ملاحظات الذكاء الاصطناعي، والتذاكر في ملف JSON واحد.",
exportButton: "تنزيل بياناتي",
exportPreparing: "جارٍ تحضير التصدير…",
exportSuccess: "تم تنزيل البيانات بنجاح",
deleteTitle: "حذف حسابي",
deleteDescription:
"سيتم إخفاء هوية ملفك الشخصي وإزالة الحقول الشخصية من سجلاتنا. سيتم الاحتفاظ بمحاولات الاختبارات (دون معلومات تعريفية) لسلامة الإحصائيات. لا يمكن التراجع عن هذا الإجراء.",
deleteButton: "محو حسابي",
deleteErasing: "جارٍ المحو…",
confirmTitle: "هل تريد محو حسابك؟",
confirmDescription:
"سنقوم بإخفاء هوية بياناتك الشخصية وتعطيل تسجيل الدخول. ستبقى التحليلات المجمعة لكنها لن تكون مرتبطة بك.",
confirmTypeDelete: "اكتب DELETE للتأكيد",
confirmErased: "تم محو حسابك. جارٍ تسجيل الخروج…",
},
feedback: {
thumbsUp: "إعجاب",
thumbsDown: "عدم إعجاب",
whatWentWrong: "ما الخطأ الذي حدث؟",
commentPlaceholder: "مثال: إجابة خاطئة، صياغة مربكة، خارج الموضوع…",
commentRequired: "من فضلك أخبرنا بالخطأ.",
submit: "إرسال الملاحظات",
},
theme: {
title: "المظهر",
light: "فاتح",
dark: "داكن",
system: "النظام",
},
};
export default ar;

View File

@@ -0,0 +1,97 @@
/** English translations. Keep keys nested by feature area.
*
* We deliberately annotate the literal with `Translations` (not `as const`)
* so sibling locale files can widen their string values without fighting
* literal-string type mismatches.
*/
export interface Translations {
common: Record<string, string>;
auth: Record<string, string>;
nav: Record<string, string>;
privacy: Record<string, string>;
feedback: Record<string, string>;
theme: Record<string, string>;
}
const en: Translations = {
common: {
cancel: "Cancel",
save: "Save",
delete: "Delete",
edit: "Edit",
close: "Close",
back: "Back",
next: "Next",
search: "Search",
loading: "Loading…",
error: "Error",
retry: "Retry",
yes: "Yes",
no: "No",
actions: "Actions",
status: "Status",
settings: "Settings",
signOut: "Sign out",
},
auth: {
signIn: "Sign in",
signInTitle: "Sign in to EnCoach",
email: "Email",
password: "Password",
forgotPassword: "Forgot password?",
needAccount: "Don't have an account?",
signUp: "Sign up",
invalidCredentials: "Invalid email or password",
},
nav: {
dashboard: "Dashboard",
courses: "Courses",
students: "Students",
teachers: "Teachers",
exams: "Exams",
reports: "Reports",
settings: "Settings",
profile: "Profile",
privacy: "Privacy Center",
reviewQueue: "Review Queue",
aiPrompts: "AI Prompts",
aiFeedback: "AI Feedback",
},
privacy: {
title: "Privacy Center",
description:
"Manage your personal data held by EnCoach under GDPR and equivalent regulations.",
exportTitle: "Download your data",
exportDescription:
"We'll package your profile, entity memberships, exam attempts, answers, AI feedback, and tickets into a single JSON file.",
exportButton: "Download my data",
exportPreparing: "Preparing export…",
exportSuccess: "Data export downloaded",
deleteTitle: "Delete my account",
deleteDescription:
"This anonymises your profile and removes personal fields from our records. Exam attempts are retained (without identifying information) for statistical integrity. This action cannot be undone.",
deleteButton: "Erase my account",
deleteErasing: "Erasing…",
confirmTitle: "Erase your account?",
confirmDescription:
"We'll anonymise your personal data and deactivate your login. Aggregate analytics will remain but will no longer be linked to you.",
confirmTypeDelete: "Type DELETE to confirm",
confirmErased: "Your account has been erased. Signing out…",
},
feedback: {
thumbsUp: "Thumbs up",
thumbsDown: "Thumbs down",
whatWentWrong: "What went wrong?",
commentPlaceholder: "e.g. Wrong answer, confusing wording, off-topic…",
commentRequired: "Please tell us what was wrong.",
submit: "Submit feedback",
},
theme: {
title: "Theme",
light: "Light",
dark: "Dark",
system: "System",
},
};
export default en;

View File

@@ -51,6 +51,14 @@
--sidebar-accent-foreground: 8 40% 78%;
--sidebar-border: 240 18% 20%;
--sidebar-ring: 8 50% 58%;
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
warm/cool axis so pairs read well together when stacked. */
--chart-1: 8 50% 54%;
--chart-2: 220 70% 52%;
--chart-3: 152 60% 42%;
--chart-4: 38 92% 50%;
--chart-5: 280 55% 55%;
}
.dark {
@@ -99,6 +107,12 @@
--sidebar-accent-foreground: 8 40% 75%;
--sidebar-border: 240 18% 13%;
--sidebar-ring: 8 50% 55%;
--chart-1: 8 52% 62%;
--chart-2: 220 65% 65%;
--chart-3: 152 50% 55%;
--chart-4: 38 75% 60%;
--chart-5: 280 55% 68%;
}
}

View File

@@ -1,4 +1,23 @@
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
/**
* Odoo REST client with transparent access-token rotation.
*
* Tokens live in ``localStorage`` under three keys:
* - ``encoach_token`` — short-lived access JWT (1h)
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
* - ``encoach_token_exp`` — epoch seconds for the access token
*
* When a request receives ``401`` *and* a refresh token is present, the client
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
* original request exactly once. Multiple concurrent 401s coalesce onto the
* same refresh promise so we never fire more than one rotation in flight.
*
* Motivation: before this change, every expired access token forced a full
* re-login, which interrupted long admin dashboards (stats, reports) multiple
* times per hour. With the refresh loop, access tokens can be short-lived
* (1h) without hurting UX, giving us the security benefit of short access
* windows without an eager logout.
*/
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
const BASE_URL = API_BASE_URL;
@@ -21,120 +40,229 @@ export class ApiError extends Error {
}
}
function getToken(): string | null {
return localStorage.getItem("encoach_token");
const ACCESS_KEY = "encoach_token";
const REFRESH_KEY = "encoach_refresh_token";
const EXP_KEY = "encoach_token_exp";
function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_KEY);
}
function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_KEY);
}
export function setToken(token: string): void {
localStorage.setItem("encoach_token", token);
localStorage.setItem(ACCESS_KEY, token);
}
export function setRefreshToken(token: string | null | undefined): void {
if (token) {
localStorage.setItem(REFRESH_KEY, token);
} else {
localStorage.removeItem(REFRESH_KEY);
}
}
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
if (epochSeconds && Number.isFinite(epochSeconds)) {
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
} else {
localStorage.removeItem(EXP_KEY);
}
}
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
export function persistTokenBundle(bundle: {
access_token?: string;
token?: string;
refresh_token?: string;
expires_in?: number;
}): void {
const access = bundle.access_token || bundle.token;
if (access) setToken(access);
setRefreshToken(bundle.refresh_token || null);
if (bundle.expires_in) {
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
}
}
export function clearToken(): void {
localStorage.removeItem("encoach_token");
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(EXP_KEY);
}
async function handleResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
// Shared refresh promise — any 401 that arrives while a refresh is in flight
// waits on the existing request instead of firing a duplicate.
let refreshPromise: Promise<boolean> | null = null;
if (response.status === 401) {
const hadToken = !!getToken();
clearToken();
// Login failure is also 401 — do not hard-redirect when no session existed.
if (hadToken) {
window.location.href = "/login";
}
throw new ApiError(401, response.statusText, data);
async function performRefresh(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) return false;
const data: {
access_token?: string;
token?: string;
refresh_token?: string;
expires_in?: number;
} = await res.json().catch(() => ({} as never));
if (!data.access_token && !data.token) return false;
persistTokenBundle(data);
return true;
} catch {
return false;
}
if (!response.ok) {
throw new ApiError(response.status, response.statusText, data);
}
return data as T;
}
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
function refreshOnce(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = performRefresh().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
const headers: Record<string, string> = { ...extra };
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
const token = getAccessToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
/**
* Query param values we know how to serialise into a URL. Arrays are joined
* with commas because that's what our Odoo controllers expect for multi-value
* filters (e.g. `?state=draft,confirmed`).
*/
export type QueryParamValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean>;
/**
* Accept any object-shaped bag of query params. We intentionally use `object`
* here rather than `Record<string, QueryParamValue>` because typed interfaces
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
* default, which would force every call-site to cast.
*/
export type QueryParams = object;
function buildUrl(path: string, params?: QueryParams): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value));
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
if (rawValue === undefined || rawValue === null) continue;
if (Array.isArray(rawValue)) {
if (rawValue.length === 0) continue;
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
continue;
}
});
url.searchParams.set(key, String(rawValue));
}
}
return url.toString();
}
async function parseResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
return data as T;
}
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
const response = await fetch(url, init);
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
// refresh token would recurse forever.
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
const hadRefresh = !!getRefreshToken();
if (hadRefresh) {
const rotated = await refreshOnce();
if (rotated) {
// Rebuild headers so the new access token is attached.
const newInit: RequestInitWithSkip = {
...init,
_skipRetry: true,
headers: {
...(init.headers as Record<string, string>),
Authorization: `Bearer ${getAccessToken()}`,
},
};
return performRequest<T>(url, newInit);
}
}
const hadAccess = !!getAccessToken();
clearToken();
if (hadAccess || hadRefresh) {
window.location.href = "/login";
}
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
}
return parseResponse<T>(response);
}
export const api = {
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const res = await fetch(buildUrl(path, params), {
async get<T>(path: string, params?: QueryParams): Promise<T> {
return performRequest<T>(buildUrl(path, params), {
method: "GET",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
async post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "POST",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async patch<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "PATCH",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async put<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "PUT",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async delete<T>(path: string): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "DELETE",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
async upload<T>(path: string, formData: FormData): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "POST",
headers,
headers: buildHeaders(undefined, false),
body: formData,
});
return handleResponse<T>(res);
},
};

Some files were not shown because too many files have changed in this diff Show More