Compare commits
4 Commits
c016a52200
...
93c530eef2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c530eef2 | ||
|
|
e70a2854f4 | ||
|
|
dcf5ea6941 | ||
|
|
47d09a3ce5 |
109
.github/workflows/ci.yml
vendored
Normal file
109
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
@@ -3,8 +3,11 @@ FROM odoo:19.0
|
|||||||
USER root
|
USER root
|
||||||
|
|
||||||
# Install all Python dependencies required by EnCoach custom addons.
|
# 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).
|
# --ignore-installed: skip uninstalling system packages that lack RECORD files
|
||||||
COPY new_project/requirements.txt /tmp/requirements.txt
|
# (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
|
RUN pip install -r /tmp/requirements.txt --break-system-packages --no-warn-script-location --ignore-installed
|
||||||
|
|
||||||
USER odoo
|
USER odoo
|
||||||
|
|||||||
267
README.md
267
README.md
@@ -1,123 +1,190 @@
|
|||||||
# EnCoach Backend v2 — Odoo 19 Adaptive Learning Platform
|
# EnCoach — Adaptive AI Learning Platform
|
||||||
|
|
||||||
The Odoo 19 backend for the **EnCoach** adaptive learning and institutional LMS platform. Provides ~377 REST API endpoints serving a React 18 SPA frontend.
|
EnCoach is a smart learning environment for individual and collaborative learning,
|
||||||
|
fully integrated with AI and equipped with intelligent, professional-grade
|
||||||
|
exercises, assessments, and e-exams.
|
||||||
|
|
||||||
## System Overview
|
This repository hosts the full stack:
|
||||||
|
|
||||||
| Component | Detail |
|
- **Backend** — Odoo 19 + ~30 `encoach_*` modules (Python 3.12, PostgreSQL 16)
|
||||||
|-----------|--------|
|
- **Frontend** — React 18 + Vite + TypeScript single-page app
|
||||||
| **Framework** | Odoo 19 Community Edition |
|
- **AI layer** — OpenAI + pgvector RAG, quality-gate validation, IELTS validator
|
||||||
| **Language** | Python 3.12+ |
|
- **Ops** — Docker Compose, JWT auth with refresh tokens, Prometheus-compatible
|
||||||
| **Database** | PostgreSQL 16 |
|
metrics, dynamic OpenAPI 3.0 spec
|
||||||
| **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 |
|
|
||||||
|
|
||||||
## 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 |
|
### Docker (recommended)
|
||||||
|---------|-----|
|
|
||||||
| **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)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
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: Python 3.12, PostgreSQL 16, Node 20, npm 10.
|
||||||
|
|
||||||
**Prerequisites:** macOS, Homebrew, Python 3.11+, PostgreSQL 15+
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install postgresql@15 wkhtmltopdf
|
# Backend
|
||||||
brew services start postgresql@15
|
./setup.sh # creates venv, installs requirements.txt
|
||||||
createuser -s $(whoami)
|
./run.sh # starts Odoo on :8069
|
||||||
|
|
||||||
chmod +x setup.sh
|
# Frontend
|
||||||
./setup.sh
|
cd frontend
|
||||||
./run.sh
|
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 |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|---|---|
|
||||||
| `ENCOACH_ODOO19_BACKEND_SRS.md` | Backend SRS v3.0 (definitive) |
|
| [`docs/PROJECT_SUMMARY.md`](docs/PROJECT_SUMMARY.md) | Release notes + architecture history |
|
||||||
| `ENCOACH_UNIFIED_SRS.md` | Unified frontend + backend SRS v2.0 |
|
| [`docs/adr/`](docs/adr/) | Architecture Decision Records (why we built it this way) |
|
||||||
| `ENCOACH_PRODUCT_DESCRIPTION.md` | Non-technical product description |
|
| [`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).
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- AI Search, Insights, Report Narrative
|
- AI Search, Insights, Report Narrative
|
||||||
""",
|
""",
|
||||||
"author": "EnCoach",
|
"author": "EnCoach",
|
||||||
"depends": ["base", "encoach_core"],
|
"depends": ["base", "encoach_core", "encoach_api"],
|
||||||
"external_dependencies": {
|
"external_dependencies": {
|
||||||
"python": ["openai", "boto3"],
|
"python": ["openai", "boto3"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
from . import ai_controller
|
from . import ai_controller
|
||||||
from . import coach_controller
|
from . import coach_controller
|
||||||
from . import media_controller
|
from . import media_controller
|
||||||
|
from . import prompt_controller
|
||||||
|
from . import feedback_controller
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from odoo import http
|
from odoo import fields, http
|
||||||
from odoo.http import request, Response
|
from odoo.http import request, Response
|
||||||
|
from odoo.addons.encoach_api.controllers.base import jwt_required
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ class AIController(http.Controller):
|
|||||||
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
||||||
|
|
||||||
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
|
# ── 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):
|
def ai_search(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
query = body.get("query", "")
|
query = body.get("query", "")
|
||||||
@@ -43,7 +45,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
|
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
|
||||||
|
|
||||||
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
|
# ── 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):
|
def ai_vector_search(self, **kw):
|
||||||
query = request.params.get("q", "")
|
query = request.params.get("q", "")
|
||||||
content_type = request.params.get("content_type")
|
content_type = request.params.get("content_type")
|
||||||
@@ -60,7 +63,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"results": [], "query": query, "error": str(e)})
|
return _json_response({"results": [], "query": query, "error": str(e)})
|
||||||
|
|
||||||
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
|
# ── 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):
|
def ai_insights(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
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."}]})
|
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
|
||||||
|
|
||||||
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
|
# ── 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):
|
def ai_alerts(self, **kw):
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
@@ -92,7 +97,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"alerts": []})
|
return _json_response({"alerts": []})
|
||||||
|
|
||||||
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
|
# ── 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):
|
def ai_report_narrative(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -107,7 +113,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"narrative": f"Report generation unavailable: {e}"})
|
return _json_response({"narrative": f"Report generation unavailable: {e}"})
|
||||||
|
|
||||||
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
|
# ── 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):
|
def ai_batch_optimize(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -122,7 +129,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
|
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
|
||||||
|
|
||||||
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
|
# ── 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):
|
def ai_grade_suggest(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -146,7 +154,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
|
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
|
||||||
|
|
||||||
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
|
# ── 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):
|
def ai_generate_resource(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -162,7 +171,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"resource": None, "status": "error", "error": str(e)})
|
return _json_response({"resource": None, "status": "error", "error": str(e)})
|
||||||
|
|
||||||
# ── POST /api/ai/detect — GPTZero AI detection ──
|
# ── 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):
|
def ai_detect(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -174,7 +184,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
|
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
|
||||||
|
|
||||||
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
|
# ── 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):
|
def plagiarism_check(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -187,7 +198,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"report_id": None, "error": str(e)})
|
return _json_response({"report_id": None, "error": str(e)})
|
||||||
|
|
||||||
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
|
# ── 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):
|
def ai_suggest_topics(self, domain_id, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -206,7 +218,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"topics": [], "error": str(e)})
|
return _json_response({"topics": [], "error": str(e)})
|
||||||
|
|
||||||
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
|
# ── 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):
|
def learning_plan_generate(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -227,7 +240,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"plan": None, "error": str(e)})
|
return _json_response({"plan": None, "error": str(e)})
|
||||||
|
|
||||||
# ── Workbench endpoints — AiWorkbench.tsx ──
|
# ── 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):
|
def workbench_outline(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -244,7 +258,8 @@ class AIController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _json_response({"chapters": [], "error": str(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):
|
def workbench_chapter(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -262,7 +277,8 @@ class AIController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _json_response({"content": "", "error": str(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):
|
def workbench_rubric(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -280,11 +296,13 @@ class AIController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _json_response({"rubric": None, "error": str(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):
|
def workbench_regenerate(self, **kw):
|
||||||
return self.workbench_chapter(**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):
|
def workbench_publish(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -315,7 +333,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
return _json_response({"status": "error", "error": str(e)}, 500)
|
||||||
|
|
||||||
# ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
|
# ── 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):
|
def ai_suggest_rubric_criteria(self, **kw):
|
||||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||||
user = validate_token()
|
user = validate_token()
|
||||||
@@ -450,7 +469,8 @@ class AIController(http.Controller):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# ── Exam generation — GenerationPage.tsx ──
|
# ── 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):
|
def exam_generate(self, module, **kw):
|
||||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||||
user = validate_token()
|
user = validate_token()
|
||||||
@@ -1252,7 +1272,8 @@ class AIController(http.Controller):
|
|||||||
return {"questions": questions}
|
return {"questions": questions}
|
||||||
|
|
||||||
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
# ── 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):
|
def generation_submit(self, **kw):
|
||||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||||
user = validate_token()
|
user = validate_token()
|
||||||
@@ -1265,12 +1286,40 @@ class AIController(http.Controller):
|
|||||||
if not title:
|
if not title:
|
||||||
return _json_response({"error": "title is required"}, 400)
|
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", "")
|
label = body.get("label", "")
|
||||||
modules = body.get("modules", {})
|
modules = body.get("modules", {})
|
||||||
skip_approval = body.get("skip_approval", False)
|
skip_approval = body.get("skip_approval", False)
|
||||||
exam_mode = body.get("exam_mode", "official")
|
exam_mode = body.get("exam_mode", "official")
|
||||||
structure_id = body.get("structure_id")
|
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 {}
|
first_mod = next(iter(modules.values()), {}) if modules else {}
|
||||||
entity_val = first_mod.get("entity", "none")
|
entity_val = first_mod.get("entity", "none")
|
||||||
entity_id = int(entity_val) if entity_val and entity_val != "none" else False
|
entity_id = int(entity_val) if entity_val and entity_val != "none" else False
|
||||||
@@ -1304,13 +1353,14 @@ class AIController(http.Controller):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||||
|
|
||||||
|
initial_status = "published" if skip_approval else "draft"
|
||||||
exam_vals = {
|
exam_vals = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"label": label,
|
"label": label,
|
||||||
"exam_mode": exam_mode,
|
"exam_mode": exam_mode,
|
||||||
"teacher_id": request.env.user.id,
|
"teacher_id": request.env.user.id,
|
||||||
"template_id": template_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_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()),
|
"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()),
|
"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",
|
"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
|
seq = 10
|
||||||
total_questions = 0
|
total_questions = 0
|
||||||
for mod_key, mod_data in modules.items():
|
for mod_key, mod_data in modules.items():
|
||||||
@@ -1381,7 +1452,7 @@ class AIController(http.Controller):
|
|||||||
for ex in (passage.get("exercises") or []):
|
for ex in (passage.get("exercises") or []):
|
||||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||||
opts = ex.get("options", [])
|
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",
|
"skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading",
|
||||||
"source_type": "passage",
|
"source_type": "passage",
|
||||||
"question_type": q_type,
|
"question_type": q_type,
|
||||||
@@ -1392,7 +1463,7 @@ class AIController(http.Controller):
|
|||||||
"difficulty": _q_difficulty_for(ex),
|
"difficulty": _q_difficulty_for(ex),
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"ai_generated": True,
|
"ai_generated": True,
|
||||||
})
|
}, cefr_level=(ex.get("cefr_level") or cefr_level))
|
||||||
question_ids.append(q.id)
|
question_ids.append(q.id)
|
||||||
|
|
||||||
sections_data = mod_data.get("sections") or []
|
sections_data = mod_data.get("sections") or []
|
||||||
@@ -1402,7 +1473,7 @@ class AIController(http.Controller):
|
|||||||
for ex in (s_data.get("exercises") or []):
|
for ex in (s_data.get("exercises") or []):
|
||||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||||
opts = ex.get("options", [])
|
opts = ex.get("options", [])
|
||||||
q = Question.create({
|
q = _create_question({
|
||||||
"skill": "listening",
|
"skill": "listening",
|
||||||
"source_type": "audio",
|
"source_type": "audio",
|
||||||
"question_type": q_type,
|
"question_type": q_type,
|
||||||
@@ -1413,12 +1484,12 @@ class AIController(http.Controller):
|
|||||||
"difficulty": _q_difficulty_for(ex),
|
"difficulty": _q_difficulty_for(ex),
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"ai_generated": True,
|
"ai_generated": True,
|
||||||
})
|
}, cefr_level=(ex.get("cefr_level") or cefr_level))
|
||||||
question_ids.append(q.id)
|
question_ids.append(q.id)
|
||||||
|
|
||||||
tasks = mod_data.get("tasks") or []
|
tasks = mod_data.get("tasks") or []
|
||||||
for t_idx, task in enumerate(tasks):
|
for t_idx, task in enumerate(tasks):
|
||||||
q = Question.create({
|
q = _create_question({
|
||||||
"skill": "writing",
|
"skill": "writing",
|
||||||
"source_type": "writing_prompt",
|
"source_type": "writing_prompt",
|
||||||
"question_type": "short_answer",
|
"question_type": "short_answer",
|
||||||
@@ -1429,12 +1500,12 @@ class AIController(http.Controller):
|
|||||||
"difficulty": q_difficulty,
|
"difficulty": q_difficulty,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"ai_generated": True,
|
"ai_generated": True,
|
||||||
})
|
}, cefr_level=cefr_level)
|
||||||
question_ids.append(q.id)
|
question_ids.append(q.id)
|
||||||
|
|
||||||
parts = mod_data.get("parts") or []
|
parts = mod_data.get("parts") or []
|
||||||
for p_idx, part in enumerate(parts):
|
for p_idx, part in enumerate(parts):
|
||||||
q = Question.create({
|
q = _create_question({
|
||||||
"skill": "speaking",
|
"skill": "speaking",
|
||||||
"source_type": "speaking_card",
|
"source_type": "speaking_card",
|
||||||
"question_type": "short_answer",
|
"question_type": "short_answer",
|
||||||
@@ -1445,7 +1516,7 @@ class AIController(http.Controller):
|
|||||||
"difficulty": q_difficulty,
|
"difficulty": q_difficulty,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"ai_generated": True,
|
"ai_generated": True,
|
||||||
})
|
}, cefr_level=cefr_level)
|
||||||
question_ids.append(q.id)
|
question_ids.append(q.id)
|
||||||
|
|
||||||
if question_ids:
|
if question_ids:
|
||||||
@@ -1455,18 +1526,37 @@ class AIController(http.Controller):
|
|||||||
})
|
})
|
||||||
total_questions += len(question_ids)
|
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({
|
return _json_response({
|
||||||
"exam_id": exam.id,
|
"exam_id": exam.id,
|
||||||
"status": exam.status,
|
"status": exam.status,
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
"total_questions": total_questions,
|
"total_questions": total_questions,
|
||||||
|
"quality": quality_summary,
|
||||||
|
"schema_validation": {
|
||||||
|
"verdict": schema_report["verdict"],
|
||||||
|
"warnings": schema_report["warnings"],
|
||||||
|
},
|
||||||
}, 201)
|
}, 201)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception("generation submit failed")
|
_logger.exception("generation submit failed")
|
||||||
return _json_response({"error": str(e)}, 500)
|
return _json_response({"error": str(e)}, 500)
|
||||||
|
|
||||||
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
|
# ── 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):
|
def ai_batch_optimize_apply(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
optimized = body.get("optimized", [])
|
optimized = body.get("optimized", [])
|
||||||
@@ -1481,7 +1571,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
return _json_response({"applied": 0, "error": str(e)}, 500)
|
||||||
|
|
||||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
# ── 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):
|
def exam_generate_save(self, module, **kw):
|
||||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||||
user = validate_token()
|
user = validate_token()
|
||||||
@@ -1521,7 +1612,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"saved": 0, "error": str(e)}, 500)
|
return _json_response({"saved": 0, "error": str(e)}, 500)
|
||||||
|
|
||||||
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
|
# ── 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):
|
def workbench_suggest_materials(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -1541,7 +1633,8 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"materials": [], "error": str(e)})
|
return _json_response({"materials": [], "error": str(e)})
|
||||||
|
|
||||||
# ── Topic content generation — adaptive ──
|
# ── 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):
|
def topic_generate_content(self, topic_id, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request, Response
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,7 +31,8 @@ class CoachController(http.Controller):
|
|||||||
return CoachService(request.env)
|
return CoachService(request.env)
|
||||||
|
|
||||||
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
|
# ── 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):
|
def coach_chat(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -43,7 +48,8 @@ class CoachController(http.Controller):
|
|||||||
return _json_response({"reply": f"I'm having trouble right now. Error: {e}"})
|
return _json_response({"reply": f"I'm having trouble right now. Error: {e}"})
|
||||||
|
|
||||||
# ── GET /api/coach/tip — AiTipBanner.tsx ──
|
# ── 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):
|
def coach_tip(self, **kw):
|
||||||
context = request.params.get("context", "general")
|
context = request.params.get("context", "general")
|
||||||
try:
|
try:
|
||||||
@@ -53,7 +59,8 @@ class CoachController(http.Controller):
|
|||||||
return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"})
|
return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"})
|
||||||
|
|
||||||
# ── POST /api/coach/explain — AiGradeExplainer.tsx ──
|
# ── 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):
|
def coach_explain(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -67,7 +74,8 @@ class CoachController(http.Controller):
|
|||||||
return _json_response({"explanation": f"Could not generate explanation: {e}"})
|
return _json_response({"explanation": f"Could not generate explanation: {e}"})
|
||||||
|
|
||||||
# ── POST /api/coach/suggest — AiStudyCoach.tsx ──
|
# ── 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):
|
def coach_suggest(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -82,7 +90,8 @@ class CoachController(http.Controller):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ── POST /api/coach/writing-help — AiWritingHelper.tsx ──
|
# ── 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):
|
def coach_writing_help(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -97,7 +106,8 @@ class CoachController(http.Controller):
|
|||||||
return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]})
|
return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]})
|
||||||
|
|
||||||
# ── POST /api/coach/hint — (unused component, wired for completeness) ──
|
# ── 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):
|
def coach_hint(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -5,6 +5,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request, Response
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,7 +56,8 @@ class MediaController(http.Controller):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── POST /api/exam/listening/media — generate listening audio ──
|
# ── 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):
|
def listening_media(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
text = body.get("text", "")
|
text = body.get("text", "")
|
||||||
@@ -72,7 +77,8 @@ class MediaController(http.Controller):
|
|||||||
return _json_response({"error": str(e)}, 500)
|
return _json_response({"error": str(e)}, 500)
|
||||||
|
|
||||||
# ── POST /api/exam/speaking/media — generate speaking prompt audio ──
|
# ── 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):
|
def speaking_media(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
text = body.get("text", "")
|
text = body.get("text", "")
|
||||||
@@ -89,7 +95,8 @@ class MediaController(http.Controller):
|
|||||||
return _json_response({"error": str(e)}, 500)
|
return _json_response({"error": str(e)}, 500)
|
||||||
|
|
||||||
# ── GET /api/exam/avatars — list ELAI avatars ──
|
# ── 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):
|
def list_avatars(self, **kw):
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.elai_service import ElaiService
|
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)})
|
return _json_response({"avatars": [], "note": str(e)})
|
||||||
|
|
||||||
# ── POST /api/exam/avatar/video — create avatar video ──
|
# ── 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):
|
def create_avatar_video(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
@@ -116,7 +124,8 @@ class MediaController(http.Controller):
|
|||||||
return _json_response({"error": str(e)}, 500)
|
return _json_response({"error": str(e)}, 500)
|
||||||
|
|
||||||
# ── GET /api/exam/avatar/video/:id — check video status ──
|
# ── 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):
|
def video_status(self, video_id, **kw):
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.elai_service import ElaiService
|
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)})
|
return _json_response({"video_id": video_id, "status": "error", "error": str(e)})
|
||||||
|
|
||||||
# ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ──
|
# ── 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):
|
def ai_generate_course(self, **kw):
|
||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
from . import ai_settings
|
from . import ai_settings
|
||||||
from . import ai_log
|
from . import ai_log
|
||||||
|
from . import ai_prompt
|
||||||
|
from . import ai_feedback
|
||||||
from . import constants
|
from . import constants
|
||||||
|
|||||||
134
backend/custom_addons/encoach_ai/models/ai_feedback.py
Normal file
134
backend/custom_addons/encoach_ai/models/ai_feedback.py
Normal 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.")
|
||||||
213
backend/custom_addons/encoach_ai/models/ai_prompt.py
Normal file
213
backend/custom_addons/encoach_ai/models/ai_prompt.py
Normal 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 + "}"
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
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_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_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
|
||||||
|
|||||||
|
@@ -5,3 +5,5 @@ from .elevenlabs_service import ElevenLabsService
|
|||||||
from .gptzero_service import GPTZeroService
|
from .gptzero_service import GPTZeroService
|
||||||
from .elai_service import ElaiService
|
from .elai_service import ElaiService
|
||||||
from .coach_service import CoachService
|
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)
|
||||||
|
|||||||
149
backend/custom_addons/encoach_ai/services/cefr_mapper.py
Normal file
149
backend/custom_addons/encoach_ai/services/cefr_mapper.py
Normal 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)
|
||||||
@@ -20,16 +20,20 @@ class OpenAIService:
|
|||||||
self._get_param = env["ir.config_parameter"].sudo().get_param
|
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.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"))
|
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", "")
|
api_key = self._get_param("encoach_ai.openai_api_key", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
import os
|
import os
|
||||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||||
if _openai_mod and 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:
|
else:
|
||||||
self.client = None
|
self.client = None
|
||||||
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
|
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):
|
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
|
||||||
try:
|
try:
|
||||||
@@ -86,6 +90,7 @@ class OpenAIService:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
|
timeout=self.request_timeout,
|
||||||
)
|
)
|
||||||
resp = self._retry_with_backoff(_call, action, model)
|
resp = self._retry_with_backoff(_call, action, model)
|
||||||
content = resp.choices[0].message.content
|
content = resp.choices[0].message.content
|
||||||
@@ -112,6 +117,7 @@ class OpenAIService:
|
|||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
response_format={"type": "json_object"},
|
response_format={"type": "json_object"},
|
||||||
|
timeout=self.request_timeout,
|
||||||
)
|
)
|
||||||
resp = self._retry_with_backoff(_call, action, model)
|
resp = self._retry_with_backoff(_call, action, model)
|
||||||
raw = resp.choices[0].message.content
|
raw = resp.choices[0].message.content
|
||||||
|
|||||||
318
backend/custom_addons/encoach_ai/services/question_validator.py
Normal file
318
backend/custom_addons/encoach_ai/services/question_validator.py
Normal 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 "{}"
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
from . import models
|
||||||
from . import controllers
|
from . import controllers
|
||||||
|
from . import utils # exposes odoo.addons.encoach_api.utils.cache
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
'external_dependencies': {
|
'external_dependencies': {
|
||||||
'python': ['PyJWT'],
|
'python': ['PyJWT'],
|
||||||
},
|
},
|
||||||
'data': [],
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/cron.xml',
|
||||||
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
from . import base
|
from . import base
|
||||||
from . import auth
|
from . import auth
|
||||||
|
from . import health
|
||||||
|
from . import openapi
|
||||||
|
from . import gdpr
|
||||||
|
|||||||
@@ -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 logging
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
|
|
||||||
@@ -13,6 +33,40 @@ from .base import (
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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):
|
class EncoachAuthController(http.Controller):
|
||||||
|
|
||||||
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
|
|||||||
if not login or not password:
|
if not login or not password:
|
||||||
return _error_response('login and password are required', 400)
|
return _error_response('login and password are required', 400)
|
||||||
|
|
||||||
# Odoo 19: session.authenticate(env, credential_dict)
|
|
||||||
credential = {
|
credential = {
|
||||||
'type': 'password',
|
'type': 'password',
|
||||||
'login': login,
|
'login': login,
|
||||||
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
|
|||||||
|
|
||||||
user = request.env['res.users'].sudo().browse(uid)
|
user = request.env['res.users'].sudo().browse(uid)
|
||||||
|
|
||||||
# Generate JWT token
|
|
||||||
secret = _get_jwt_secret()
|
secret = _get_jwt_secret()
|
||||||
if not secret:
|
if not secret:
|
||||||
return _error_response('JWT not configured on server', 500)
|
return _error_response('JWT not configured on server', 500)
|
||||||
|
|
||||||
token = pyjwt.encode(
|
# User-Agent / IP are best-effort — some proxies strip them; we
|
||||||
{'user_id': user.id, 'exp': int(time.time()) + 86400},
|
# record whatever we get but never refuse login because they're
|
||||||
secret, algorithm='HS256',
|
# 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 = []
|
permissions = []
|
||||||
if hasattr(user, 'get_all_permissions'):
|
if hasattr(user, 'get_all_permissions'):
|
||||||
permissions = user.get_all_permissions().mapped('code')
|
permissions = user.get_all_permissions().mapped('code')
|
||||||
|
|
||||||
# Update last login
|
|
||||||
user.write({'last_login': fields.Datetime.now()})
|
user.write({'last_login': fields.Datetime.now()})
|
||||||
|
|
||||||
return _json_response({
|
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),
|
'user': self._user_to_dict(user),
|
||||||
'permissions': permissions,
|
'permissions': permissions,
|
||||||
})
|
})
|
||||||
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
|
|||||||
_logger.exception('login failed')
|
_logger.exception('login failed')
|
||||||
return _error_response(str(e), 500)
|
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)
|
# GET /api/user (returns current authenticated user)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
|
|||||||
@http.route('/api/logout', type='http', auth='public',
|
@http.route('/api/logout', type='http', auth='public',
|
||||||
methods=['POST'], csrf=False)
|
methods=['POST'], csrf=False)
|
||||||
def logout(self, **kw):
|
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})
|
return _json_response({'ok': True})
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
|
|
||||||
@@ -9,14 +10,75 @@ from odoo.http import request
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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_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_exists_cache = {}
|
||||||
_USER_CACHE_TTL = 60
|
_USER_CACHE_TTL = 60
|
||||||
_USER_CACHE_MAX = 200
|
_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():
|
def _get_jwt_secret():
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
|
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
|
||||||
@@ -48,6 +110,12 @@ def validate_token():
|
|||||||
return None
|
return None
|
||||||
except pyjwt.InvalidTokenError:
|
except pyjwt.InvalidTokenError:
|
||||||
return None
|
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")
|
user_id = payload.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return None
|
return None
|
||||||
@@ -70,26 +138,95 @@ def validate_token():
|
|||||||
|
|
||||||
|
|
||||||
def jwt_required(func):
|
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)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
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()
|
user = validate_token()
|
||||||
if not user:
|
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)
|
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
|
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):
|
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):
|
def _error_response(message, status=400, code=None):
|
||||||
body = {"error": message}
|
body = {"error": message}
|
||||||
if code:
|
if code:
|
||||||
body["code"] = 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():
|
def _get_json_body():
|
||||||
@@ -99,6 +236,31 @@ def _get_json_body():
|
|||||||
return {}
|
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'):
|
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.
|
"""Paginate an Odoo model search or extract params from a kwargs dict.
|
||||||
|
|
||||||
|
|||||||
316
backend/custom_addons/encoach_api/controllers/gdpr.py
Normal file
316
backend/custom_addons/encoach_api/controllers/gdpr.py
Normal 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)
|
||||||
65
backend/custom_addons/encoach_api/controllers/health.py
Normal file
65
backend/custom_addons/encoach_api/controllers/health.py
Normal 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)
|
||||||
265
backend/custom_addons/encoach_api/controllers/openapi.py
Normal file
265
backend/custom_addons/encoach_api/controllers/openapi.py
Normal 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")
|
||||||
12
backend/custom_addons/encoach_api/data/cron.xml
Normal file
12
backend/custom_addons/encoach_api/data/cron.xml
Normal 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>
|
||||||
2
backend/custom_addons/encoach_api/models/__init__.py
Normal file
2
backend/custom_addons/encoach_api/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import jwt_token
|
||||||
|
from . import gdpr_erasure
|
||||||
35
backend/custom_addons/encoach_api/models/gdpr_erasure.py
Normal file
35
backend/custom_addons/encoach_api/models/gdpr_erasure.py
Normal 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()
|
||||||
105
backend/custom_addons/encoach_api/models/jwt_token.py
Normal file
105
backend/custom_addons/encoach_api/models/jwt_token.py
Normal 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
|
||||||
@@ -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
backend/custom_addons/encoach_api/tests/__init__.py
Normal file
1
backend/custom_addons/encoach_api/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import test_health
|
||||||
27
backend/custom_addons/encoach_api/tests/test_health.py
Normal file
27
backend/custom_addons/encoach_api/tests/test_health.py
Normal 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]}",
|
||||||
|
)
|
||||||
1
backend/custom_addons/encoach_api/utils/__init__.py
Normal file
1
backend/custom_addons/encoach_api/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import cache # noqa: F401
|
||||||
106
backend/custom_addons/encoach_api/utils/cache.py
Normal file
106
backend/custom_addons/encoach_api/utils/cache.py
Normal 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
|
||||||
@@ -7,4 +7,4 @@ from . import exam_schedules
|
|||||||
from . import rubrics
|
from . import rubrics
|
||||||
from . import approval_workflows
|
from . import approval_workflows
|
||||||
from . import entities
|
from . import entities
|
||||||
from . import exam_session
|
from . import review_workflow
|
||||||
|
|||||||
@@ -256,36 +256,46 @@ class ApprovalWorkflowController(http.Controller):
|
|||||||
auth='none', methods=['POST'], csrf=False)
|
auth='none', methods=['POST'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def approve_request(self, req_id, **kw):
|
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:
|
try:
|
||||||
body = _get_json_body()
|
body = _get_json_body()
|
||||||
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
||||||
if not req_rec.exists():
|
if not req_rec.exists():
|
||||||
return _error_response('Request not found', 404)
|
return _error_response('Request not found', 404)
|
||||||
|
|
||||||
stage = req_rec.current_stage_id
|
with request.env.cr.savepoint():
|
||||||
if stage:
|
stage = req_rec.current_stage_id
|
||||||
stage.write({
|
if stage:
|
||||||
'status': 'approved',
|
stage.write({
|
||||||
'comment': body.get('comment', ''),
|
'status': 'approved',
|
||||||
'acted_at': datetime.now(),
|
'comment': body.get('comment', ''),
|
||||||
})
|
'acted_at': datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
# Move to next stage or finalize.
|
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
|
||||||
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
|
stage_ids = [s.id for s in stages]
|
||||||
stage_ids = [s.id for s in stages]
|
try:
|
||||||
try:
|
idx = stage_ids.index(stage.id) if stage else -1
|
||||||
idx = stage_ids.index(stage.id) if stage else -1
|
except ValueError:
|
||||||
except ValueError:
|
idx = -1
|
||||||
idx = -1
|
|
||||||
|
|
||||||
if 0 <= idx and idx + 1 < len(stage_ids):
|
if 0 <= idx and idx + 1 < len(stage_ids):
|
||||||
next_stage = request.env['encoach.approval.stage'].sudo().browse(stage_ids[idx + 1])
|
next_stage = request.env['encoach.approval.stage'].sudo().browse(
|
||||||
req_rec.write({
|
stage_ids[idx + 1]
|
||||||
'current_stage_id': next_stage.id,
|
)
|
||||||
'state': 'in_progress',
|
req_rec.write({
|
||||||
})
|
'current_stage_id': next_stage.id,
|
||||||
else:
|
'state': 'in_progress',
|
||||||
req_rec.write({'state': 'approved'})
|
})
|
||||||
|
else:
|
||||||
|
req_rec.write({'state': 'approved'})
|
||||||
|
|
||||||
return _json_response({'success': True, 'id': req_id,
|
return _json_response({'success': True, 'id': req_id,
|
||||||
'state': req_rec.state})
|
'state': req_rec.state})
|
||||||
@@ -297,19 +307,26 @@ class ApprovalWorkflowController(http.Controller):
|
|||||||
auth='none', methods=['POST'], csrf=False)
|
auth='none', methods=['POST'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def reject_request(self, req_id, **kw):
|
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:
|
try:
|
||||||
body = _get_json_body()
|
body = _get_json_body()
|
||||||
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
||||||
if not req_rec.exists():
|
if not req_rec.exists():
|
||||||
return _error_response('Request not found', 404)
|
return _error_response('Request not found', 404)
|
||||||
stage = req_rec.current_stage_id
|
with request.env.cr.savepoint():
|
||||||
if stage:
|
stage = req_rec.current_stage_id
|
||||||
stage.write({
|
if stage:
|
||||||
'status': 'rejected',
|
stage.write({
|
||||||
'comment': body.get('comment', ''),
|
'status': 'rejected',
|
||||||
'acted_at': datetime.now(),
|
'comment': body.get('comment', ''),
|
||||||
})
|
'acted_at': datetime.now(),
|
||||||
req_rec.write({'state': 'rejected'})
|
})
|
||||||
|
req_rec.write({'state': 'rejected'})
|
||||||
return _json_response({'success': True, 'id': req_id,
|
return _json_response({'success': True, 'id': req_id,
|
||||||
'state': req_rec.state})
|
'state': req_rec.state})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
@@ -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)
|
||||||
@@ -11,5 +11,4 @@ from . import exam_custom_section
|
|||||||
from . import exam_assignment
|
from . import exam_assignment
|
||||||
from . import exam_schedule
|
from . import exam_schedule
|
||||||
from . import exam_structure
|
from . import exam_structure
|
||||||
from . import student_attempt
|
|
||||||
from . import approval
|
from . import approval
|
||||||
|
|||||||
@@ -39,7 +39,22 @@ class EncoachExamCustom(models.Model):
|
|||||||
randomize_questions = fields.Boolean(default=False)
|
randomize_questions = fields.Boolean(default=False)
|
||||||
status = fields.Selection([
|
status = fields.Selection([
|
||||||
('draft', 'Draft'),
|
('draft', 'Draft'),
|
||||||
|
('pending_review', 'Pending Review'),
|
||||||
('published', 'Published'),
|
('published', 'Published'),
|
||||||
('archived', 'Archived'),
|
('archived', 'Archived'),
|
||||||
], default='draft', required=True)
|
], default='draft', required=True)
|
||||||
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')
|
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.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -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):
|
class EncoachQuestion(models.Model):
|
||||||
@@ -67,3 +71,55 @@ class EncoachQuestion(models.Model):
|
|||||||
format_validated = fields.Boolean(default=False)
|
format_validated = fields.Boolean(default=False)
|
||||||
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
||||||
topic_id = fields.Many2one('encoach.topic', 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
|
||||||
|
|||||||
@@ -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')
|
|
||||||
@@ -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_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_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_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_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_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
|
access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
@@ -20,6 +20,7 @@
|
|||||||
'openeducat_library',
|
'openeducat_library',
|
||||||
'encoach_core',
|
'encoach_core',
|
||||||
'encoach_api',
|
'encoach_api',
|
||||||
|
'encoach_ai',
|
||||||
'encoach_taxonomy',
|
'encoach_taxonomy',
|
||||||
'encoach_resources',
|
'encoach_resources',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from . import faq
|
|||||||
from . import stats
|
from . import stats
|
||||||
from . import tickets
|
from . import tickets
|
||||||
from . import payments
|
from . import payments
|
||||||
|
from . import paymob
|
||||||
from . import platform_settings
|
from . import platform_settings
|
||||||
from . import training
|
from . import training
|
||||||
from . import reports
|
from . import reports
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class AttendanceController(http.Controller):
|
|||||||
domain.append(('attendance_id.attendance_date', '=', kw['date']))
|
domain.append(('attendance_id.attendance_date', '=', kw['date']))
|
||||||
recs = M.search(domain, limit=200, order='id desc')
|
recs = M.search(domain, limit=200, order='id desc')
|
||||||
data = [_ser_att(r) for r in recs]
|
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:
|
except Exception as e:
|
||||||
_logger.exception('list_attendance')
|
_logger.exception('list_attendance')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class GradesController(http.Controller):
|
|||||||
'date': str(r.create_date.date()) if r.create_date else '',
|
'date': str(r.create_date.date()) if r.create_date else '',
|
||||||
'type': r.state or 'graded',
|
'type': r.state or 'graded',
|
||||||
})
|
})
|
||||||
return _json_response({'data': data})
|
return _json_response({'items': data, 'data': data, 'total': len(data)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('list_grades')
|
_logger.exception('list_grades')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|||||||
@@ -116,10 +116,36 @@ class PaymentRecordsController(http.Controller):
|
|||||||
methods=['GET'], csrf=False)
|
methods=['GET'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def list_paymob_orders(self, **kw):
|
def list_paymob_orders(self, **kw):
|
||||||
"""Paymob integration is not yet wired — return empty list.
|
"""List Paymob orders for the calling user.
|
||||||
This endpoint exists so the UI renders cleanly instead of calling a
|
|
||||||
404-returning route."""
|
Admins see all orders; regular users see only their own. Orders are
|
||||||
return _json_response({
|
populated by the paymob.py controller's checkout + webhook flow.
|
||||||
'data': [], 'items': [], 'total': 0,
|
"""
|
||||||
'message': 'Paymob integration not configured.',
|
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)
|
||||||
|
|||||||
351
backend/custom_addons/encoach_lms_api/controllers/paymob.py
Normal file
351
backend/custom_addons/encoach_lms_api/controllers/paymob.py
Normal 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)
|
||||||
@@ -23,35 +23,24 @@ from odoo.http import request
|
|||||||
from odoo.addons.encoach_api.controllers.base import (
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
jwt_required, _json_response, _error_response, _paginate,
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
|
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
|
||||||
|
|
||||||
# CEFR ordering + band ranges used when an attempt didn't store cefr_level.
|
from odoo.addons.encoach_ai.services.cefr_mapper import (
|
||||||
_CEFR_BY_BAND = [
|
band_to_cefr as _cefr_code_for_band,
|
||||||
(9.0, 'C2'), (7.0, 'C1'), (5.5, 'B2'),
|
normalize_cefr as _normalize_cefr,
|
||||||
(4.0, 'B1'), (3.0, 'A2'), (2.0, 'A1'), (0.0, 'Pre-A1'),
|
)
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _cefr_for_band(band):
|
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
|
return None
|
||||||
for threshold, label in _CEFR_BY_BAND:
|
return _normalize_cefr(code) or code
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _duration_minutes(att):
|
def _duration_minutes(att):
|
||||||
@@ -232,6 +221,23 @@ class ReportsController(http.Controller):
|
|||||||
include), ``months`` (trend horizon; default 6).
|
include), ``months`` (trend horizon; default 6).
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
Att = request.env['encoach.student.attempt'].sudo()
|
||||||
domain = _build_attempt_domain(kw)
|
domain = _build_attempt_domain(kw)
|
||||||
|
|
||||||
@@ -242,55 +248,88 @@ class ReportsController(http.Controller):
|
|||||||
if threshold > 0:
|
if threshold > 0:
|
||||||
domain.append(('overall_band', '>=', threshold / 10.0))
|
domain.append(('overall_band', '>=', threshold / 10.0))
|
||||||
|
|
||||||
attempts = Att.search(domain)
|
# --- Per-skill average via SQL aggregation ------------------
|
||||||
|
# Previously each of the four skill averages required pulling the
|
||||||
skill_totals = {k: [0.0, 0] for k in
|
# full attempt rowset into Python and summing per record. This now
|
||||||
('reading', 'listening', 'writing', 'speaking')}
|
# runs as a single ``read_group`` per skill (avg in SQL), dropping
|
||||||
for att in attempts:
|
# the hot path from O(N) Python iterations to a constant number of
|
||||||
for skill, field in (('reading', att.reading_band),
|
# round-trips regardless of attempt volume.
|
||||||
('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
|
|
||||||
|
|
||||||
by_module = []
|
by_module = []
|
||||||
for skill in ('Reading', 'Listening', 'Writing', 'Speaking'):
|
skill_attempts_considered = 0
|
||||||
total, n = skill_totals[skill.lower()]
|
for display, fname in (
|
||||||
avg = round((total / n) * 10, 1) if n else 0
|
('Reading', 'reading_band'),
|
||||||
by_module.append({'module': skill, 'score': avg, 'n': n})
|
('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:
|
try:
|
||||||
months = max(1, min(24, int(kw.get('months') or 6)))
|
months = max(1, min(24, int(kw.get('months') or 6)))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
months = 6
|
months = 6
|
||||||
|
trend_domain = domain + [('overall_band', '!=', False)]
|
||||||
month_buckets = defaultdict(lambda: [0.0, 0])
|
month_rows = Att.read_group(
|
||||||
for att in attempts:
|
trend_domain,
|
||||||
ct = _attempt_completed_at(att) or att.started_at
|
fields=['overall_band:avg'],
|
||||||
if not ct or att.overall_band is None:
|
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
|
continue
|
||||||
key = ct.strftime('%Y-%m')
|
try:
|
||||||
month_buckets[key][0] += att.overall_band
|
ref = datetime.strptime(label, '%B %Y')
|
||||||
month_buckets[key][1] += 1
|
except ValueError:
|
||||||
|
continue
|
||||||
|
key = ref.strftime('%Y-%m')
|
||||||
|
month_buckets[key] = (row.get('overall_band') or 0.0,
|
||||||
|
row.get('__count') or 0)
|
||||||
|
|
||||||
trend = []
|
trend = []
|
||||||
today = datetime.now().replace(day=1)
|
today = datetime.now().replace(day=1)
|
||||||
for i in range(months - 1, -1, -1):
|
for i in range(months - 1, -1, -1):
|
||||||
ref = (today - timedelta(days=31 * i)).replace(day=1)
|
ref = (today - timedelta(days=31 * i)).replace(day=1)
|
||||||
key = ref.strftime('%Y-%m')
|
key = ref.strftime('%Y-%m')
|
||||||
total, n = month_buckets.get(key, (0.0, 0))
|
avg_val, n = month_buckets.get(key, (0.0, 0))
|
||||||
avg = round((total / n) * 10, 1) if n else 0
|
trend.append({
|
||||||
trend.append({'month': ref.strftime('%b'), 'avg': avg,
|
'month': ref.strftime('%b'),
|
||||||
'period': key, 'n': n})
|
'avg': round(avg_val * 10, 1) if n else 0,
|
||||||
|
'period': key,
|
||||||
|
'n': n,
|
||||||
|
})
|
||||||
|
|
||||||
level_buckets = defaultdict(int)
|
# --- CEFR distribution via read_group by cefr_level ---------
|
||||||
for att in attempts:
|
dist_rows = Att.read_group(
|
||||||
label = _normalize_cefr(att.cefr_level) \
|
domain + [('cefr_level', '!=', False)],
|
||||||
or _cefr_for_band(att.overall_band)
|
fields=[],
|
||||||
|
groupby=['cefr_level'],
|
||||||
|
)
|
||||||
|
level_buckets = {}
|
||||||
|
for row in dist_rows:
|
||||||
|
label = _normalize_cefr(row.get('cefr_level'))
|
||||||
if label:
|
if label:
|
||||||
level_buckets[label] += 1
|
level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0)
|
||||||
palette = {
|
palette = {
|
||||||
'Pre-A1': 'hsl(0, 0%, 55%)',
|
'Pre-A1': 'hsl(0, 0%, 55%)',
|
||||||
'A1': 'hsl(0, 72%, 51%)',
|
'A1': 'hsl(0, 72%, 51%)',
|
||||||
@@ -310,35 +349,39 @@ class ReportsController(http.Controller):
|
|||||||
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
|
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
|
||||||
]
|
]
|
||||||
|
|
||||||
entity_buckets = defaultdict(lambda: [0.0, 0, 'Unassigned'])
|
# --- Entity comparison via read_group by entity_id ----------
|
||||||
for att in attempts:
|
entity_rows = Att.read_group(
|
||||||
if att.overall_band is None:
|
domain + [('overall_band', '!=', False)],
|
||||||
continue
|
fields=['overall_band:avg'],
|
||||||
eid = att.entity_id.id or 0
|
groupby=['entity_id'],
|
||||||
entity_buckets[eid][0] += att.overall_band
|
)
|
||||||
entity_buckets[eid][1] += 1
|
|
||||||
entity_buckets[eid][2] = att.entity_id.name or 'Unassigned'
|
|
||||||
comparison = []
|
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({
|
comparison.append({
|
||||||
'entity_id': eid or None,
|
'entity_id': eid,
|
||||||
'entity_name': name,
|
'entity_name': ename or 'Unassigned',
|
||||||
'avg': round((total / n) * 10, 1) if n else 0,
|
'avg': round(avg_val * 10, 1),
|
||||||
'attempts': n,
|
'attempts': row.get('__count') or 0,
|
||||||
})
|
})
|
||||||
comparison.sort(key=lambda r: -r['avg'])
|
comparison.sort(key=lambda r: -r['avg'])
|
||||||
|
|
||||||
return _json_response({
|
total_considered = Att.search_count(domain)
|
||||||
|
payload = {
|
||||||
'by_module': by_module,
|
'by_module': by_module,
|
||||||
'trend': trend,
|
'trend': trend,
|
||||||
'distribution': distribution,
|
'distribution': distribution,
|
||||||
'comparison': comparison,
|
'comparison': comparison,
|
||||||
'meta': {
|
'meta': {
|
||||||
'attempts_considered': len(attempts),
|
'attempts_considered': total_considered,
|
||||||
'threshold': threshold,
|
'threshold': threshold,
|
||||||
'months': months,
|
'months': months,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
_cache_put(cache_key, payload, ttl_seconds=30)
|
||||||
|
return _json_response(payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('stats-corporate report failed')
|
_logger.exception('stats-corporate report failed')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class TimetableController(http.Controller):
|
|||||||
domain.append(('batch_id', '=', int(kw['batch_id'])))
|
domain.append(('batch_id', '=', int(kw['batch_id'])))
|
||||||
recs = M.search(domain, limit=200, order='id desc')
|
recs = M.search(domain, limit=200, order='id desc')
|
||||||
data = [_ser_session(r) for r in recs]
|
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:
|
except Exception as e:
|
||||||
_logger.exception('list_timetable')
|
_logger.exception('list_timetable')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class UsersRolesController(http.Controller):
|
|||||||
total = M.search_count(domain)
|
total = M.search_count(domain)
|
||||||
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
|
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
|
||||||
items = [_ser_role(r) for r in recs]
|
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:
|
except Exception as e:
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ class UsersRolesController(http.Controller):
|
|||||||
domain.append(('code', 'ilike', kw['search']))
|
domain.append(('code', 'ilike', kw['search']))
|
||||||
recs = M.search(domain, limit=500, order='topic, code')
|
recs = M.search(domain, limit=500, order='topic, code')
|
||||||
items = [_ser_perm(r) for r in recs]
|
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:
|
except Exception as e:
|
||||||
return _error_response(str(e), 500)
|
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],
|
'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids],
|
||||||
'effective_permission_count': len(all_perms),
|
'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:
|
except Exception as e:
|
||||||
_logger.exception('list_users_with_roles')
|
_logger.exception('list_users_with_roles')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ from . import course_ext
|
|||||||
from . import asset
|
from . import asset
|
||||||
from . import ticket
|
from . import ticket
|
||||||
from . import training
|
from . import training
|
||||||
|
from . import paymob_order
|
||||||
|
|||||||
92
backend/custom_addons/encoach_lms_api/models/paymob_order.py
Normal file
92
backend/custom_addons/encoach_lms_api/models/paymob_order.py
Normal 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,
|
||||||
|
})
|
||||||
@@ -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):
|
class EncoachTicket(models.Model):
|
||||||
@@ -35,3 +39,129 @@ class EncoachTicket(models.Model):
|
|||||||
entity_id = fields.Many2one('encoach.entity', string='Entity')
|
entity_id = fields.Many2one('encoach.entity', string='Entity')
|
||||||
|
|
||||||
resolved_at = fields.Datetime('Resolved At')
|
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
|
||||||
|
|||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -5,7 +5,7 @@
|
|||||||
'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping',
|
'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping',
|
||||||
'author': 'EnCoach',
|
'author': 'EnCoach',
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
'depends': ['encoach_core', 'encoach_exam_template'],
|
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_ai'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/cat_session_views.xml',
|
'views/cat_session_views.xml',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from odoo.http import request
|
|||||||
from odoo.addons.encoach_api.controllers.base import (
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -15,18 +16,6 @@ LEARNING_RATE = 0.3
|
|||||||
SEM_THRESHOLD = 0.3
|
SEM_THRESHOLD = 0.3
|
||||||
MAX_QUESTIONS = 40
|
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):
|
def _irt_probability(theta, a, b, c):
|
||||||
"""3PL IRT probability."""
|
"""3PL IRT probability."""
|
||||||
|
|||||||
@@ -1,83 +1,20 @@
|
|||||||
class CefrMapper:
|
"""Thin re-export of the canonical CEFR mapper for backward compat.
|
||||||
"""Maps IRT theta values to CEFR levels and IELTS band scores."""
|
|
||||||
|
|
||||||
THETA_TO_CEFR = [
|
The canonical implementation lives in ``encoach_ai.services.cefr_mapper``.
|
||||||
(-4.0, -2.5, 'pre_a1'),
|
This shim is kept so existing imports like
|
||||||
(-2.5, -1.5, 'a1'),
|
``from odoo.addons.encoach_placement.services.cefr_mapper import CefrMapper``
|
||||||
(-1.5, -0.5, 'a2'),
|
continue to work. See P0.9 in §21 of docs/PROJECT_SUMMARY.md.
|
||||||
(-0.5, 0.5, 'b1'),
|
"""
|
||||||
(0.5, 1.5, 'b2'),
|
|
||||||
(1.5, 2.5, 'c1'),
|
|
||||||
(2.5, 4.0, 'c2'),
|
|
||||||
]
|
|
||||||
|
|
||||||
CEFR_TO_BAND = {
|
from odoo.addons.encoach_ai.services.cefr_mapper import ( # noqa: F401
|
||||||
'pre_a1': 2.0,
|
CefrMapper,
|
||||||
'a1': 3.0,
|
band_to_cefr,
|
||||||
'a2': 4.0,
|
theta_to_cefr,
|
||||||
'b1': 5.0,
|
theta_to_band,
|
||||||
'b2': 6.5,
|
cefr_to_band,
|
||||||
'c1': 7.5,
|
normalize_cefr,
|
||||||
'c2': 9.0,
|
cefr_label,
|
||||||
}
|
THETA_TO_CEFR,
|
||||||
|
CEFR_TO_BAND,
|
||||||
CEFR_LABELS = {
|
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)
|
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ BAND_TO_CEFR = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _band_to_cefr(band):
|
from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402
|
||||||
for threshold, level in BAND_TO_CEFR:
|
|
||||||
if band >= threshold:
|
|
||||||
return level
|
|
||||||
return 'pre_a1'
|
|
||||||
|
|
||||||
|
|
||||||
def _question_to_student_dict(q):
|
def _question_to_student_dict(q):
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ BAND_TO_CEFR = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _band_to_cefr(band):
|
from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402
|
||||||
for threshold, level in BAND_TO_CEFR:
|
|
||||||
if band >= threshold:
|
|
||||||
return level
|
|
||||||
return 'pre_a1'
|
|
||||||
|
|
||||||
|
|
||||||
def _recompute_bands(attempt):
|
def _recompute_bands(attempt):
|
||||||
|
|||||||
@@ -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):
|
class EncoachStudentAttempt(models.Model):
|
||||||
@@ -39,3 +43,31 @@ class EncoachStudentAttempt(models.Model):
|
|||||||
score_ids = fields.One2many('encoach.score', 'attempt_id')
|
score_ids = fields.One2many('encoach.score', 'attempt_id')
|
||||||
autosave_data = fields.Text()
|
autosave_data = fields.Text()
|
||||||
feedback_ids = fields.One2many('encoach.feedback', 'attempt_id')
|
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
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ class EncoachTaxonomyController(http.Controller):
|
|||||||
try:
|
try:
|
||||||
recs = request.env['encoach.subject'].sudo().search([])
|
recs = request.env['encoach.subject'].sudo().search([])
|
||||||
items = [_subject_dict(s) for s in recs]
|
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:
|
except Exception as e:
|
||||||
_logger.exception('list_subjects')
|
_logger.exception('list_subjects')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
@@ -205,7 +206,9 @@ class EncoachTaxonomyController(http.Controller):
|
|||||||
if kw.get('subject_id'):
|
if kw.get('subject_id'):
|
||||||
domain_filter.append(('subject_id', '=', int(kw['subject_id'])))
|
domain_filter.append(('subject_id', '=', int(kw['subject_id'])))
|
||||||
recs = request.env['encoach.domain'].sudo().search(domain_filter)
|
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:
|
except Exception as e:
|
||||||
return _error_response(str(e), 500)
|
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']))])
|
domains = request.env['encoach.domain'].sudo().search([('subject_id', '=', int(kw['subject_id']))])
|
||||||
domain_filter.append(('domain_id', 'in', domains.ids))
|
domain_filter.append(('domain_id', 'in', domains.ids))
|
||||||
recs = request.env['encoach.topic'].sudo().search(domain_filter)
|
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:
|
except Exception as e:
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,17 @@ class EncoachEmbedding(models.Model):
|
|||||||
content_text = fields.Text()
|
content_text = fields.Text()
|
||||||
metadata_json = fields.Text(default='{}')
|
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(
|
_content_unique = models.Constraint(
|
||||||
'UNIQUE(content_type, content_id)',
|
'UNIQUE(content_type, content_id, chunk_index)',
|
||||||
'Each content item can only have one embedding.',
|
'Each content item chunk can only have one embedding.',
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
@@ -82,27 +90,46 @@ class EncoachEmbedding(models.Model):
|
|||||||
return index_all(self.env)
|
return index_all(self.env)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def similarity_search(self, query_vector, *, content_type=None, limit=10):
|
def similarity_search(self, query_vector, *, content_type=None, limit=10,
|
||||||
"""Find similar embeddings using cosine distance."""
|
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) + ']'
|
vec_str = '[' + ','.join(str(v) for v in query_vector) + ']'
|
||||||
where = "WHERE embedding IS NOT NULL"
|
conditions = ["embedding IS NOT NULL"]
|
||||||
params = [vec_str, limit]
|
extra_params = []
|
||||||
if content_type:
|
if content_type:
|
||||||
where += " AND content_type = %s"
|
conditions.append("content_type = %s")
|
||||||
params = [vec_str, content_type, limit]
|
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"""
|
query = f"""
|
||||||
SELECT id, content_type, content_id, content_text, metadata_json,
|
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
|
1 - (embedding <=> %s::vector) AS similarity
|
||||||
FROM encoach_embedding
|
FROM encoach_embedding
|
||||||
{where}
|
{where}
|
||||||
ORDER BY embedding <=> %s::vector
|
ORDER BY embedding <=> %s::vector
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
"""
|
"""
|
||||||
if content_type:
|
params = [vec_str] + extra_params + [vec_str, limit]
|
||||||
self.env.cr.execute(query, (vec_str, content_type, vec_str, limit))
|
self.env.cr.execute(query, params)
|
||||||
else:
|
|
||||||
self.env.cr.execute(query, (vec_str, vec_str, limit))
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for row in self.env.cr.dictfetchall():
|
for row in self.env.cr.dictfetchall():
|
||||||
@@ -117,6 +144,12 @@ class EncoachEmbedding(models.Model):
|
|||||||
'content_id': row['content_id'],
|
'content_id': row['content_id'],
|
||||||
'text': row['content_text'],
|
'text': row['content_text'],
|
||||||
'metadata': metadata,
|
'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),
|
'similarity': round(row['similarity'], 4),
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -1,13 +1,81 @@
|
|||||||
"""Embedding service — encode text and manage vector storage."""
|
"""Embedding service — encode text and manage vector storage."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_model_instance = None
|
_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():
|
def _get_model():
|
||||||
"""Lazy-load the sentence-transformers model (cached across calls)."""
|
"""Lazy-load the sentence-transformers model (cached across calls)."""
|
||||||
@@ -47,42 +115,77 @@ class EmbeddingService:
|
|||||||
return [e.tolist() for e in embeddings]
|
return [e.tolist() for e in embeddings]
|
||||||
|
|
||||||
def upsert(self, content_type, content_id, text, metadata=None):
|
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:
|
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():
|
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_type', '=', content_type),
|
||||||
('content_id', '=', content_id),
|
('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])
|
records = []
|
||||||
meta_str = json.dumps(metadata or {})
|
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:
|
def search(self, query, *, content_type=None, limit=10,
|
||||||
existing.write({
|
course_id=None, subject_id=None, entity_id=None,
|
||||||
'content_text': text[:10000],
|
taxonomy=None):
|
||||||
'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):
|
|
||||||
"""Semantic search — encode query and find similar content.
|
"""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:
|
Returns:
|
||||||
list of dicts with text, metadata, similarity score
|
list of dicts with text, metadata, similarity score
|
||||||
"""
|
"""
|
||||||
@@ -95,6 +198,10 @@ class EmbeddingService:
|
|||||||
vectors[0],
|
vectors[0],
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
course_id=course_id,
|
||||||
|
subject_id=subject_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
taxonomy=taxonomy,
|
||||||
)
|
)
|
||||||
latency = int((time.time() - t0) * 1000)
|
latency = int((time.time() - t0) * 1000)
|
||||||
_logger.info("Vector search for '%s' returned %d results in %dms",
|
_logger.info("Vector search for '%s' returned %d results in %dms",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ MODEL_CONFIG = [
|
|||||||
'text_field': 'name',
|
'text_field': 'name',
|
||||||
'description_field': 'description',
|
'description_field': 'description',
|
||||||
'metadata_fields': [],
|
'metadata_fields': [],
|
||||||
|
'course_field': 'id',
|
||||||
|
'subject_field': 'subject_id',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'model': 'encoach.resource',
|
'model': 'encoach.resource',
|
||||||
@@ -18,13 +20,19 @@ MODEL_CONFIG = [
|
|||||||
'text_field': 'name',
|
'text_field': 'name',
|
||||||
'description_field': 'content',
|
'description_field': 'content',
|
||||||
'metadata_fields': ['type', 'cefr_level', 'difficulty'],
|
'metadata_fields': ['type', 'cefr_level', 'difficulty'],
|
||||||
|
'course_field': 'course_id',
|
||||||
|
'subject_field': 'subject_id',
|
||||||
|
'entity_field': 'entity_id',
|
||||||
|
'taxonomy_field': 'type',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'model': 'encoach.question',
|
'model': 'encoach.question',
|
||||||
'content_type': 'question',
|
'content_type': 'question',
|
||||||
'text_field': 'name',
|
'text_field': 'stem',
|
||||||
'description_field': None,
|
'description_field': None,
|
||||||
'metadata_fields': ['question_type', 'difficulty', 'skill'],
|
'metadata_fields': ['question_type', 'difficulty', 'skill'],
|
||||||
|
'subject_field': 'subject_id',
|
||||||
|
'taxonomy_field': 'skill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'model': 'encoach.course.module',
|
'model': 'encoach.course.module',
|
||||||
@@ -32,6 +40,8 @@ MODEL_CONFIG = [
|
|||||||
'text_field': 'name',
|
'text_field': 'name',
|
||||||
'description_field': 'description',
|
'description_field': 'description',
|
||||||
'metadata_fields': ['skill'],
|
'metadata_fields': ['skill'],
|
||||||
|
'course_field': 'course_id',
|
||||||
|
'taxonomy_field': 'skill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'model': 'encoach.ai.generation.log',
|
'model': 'encoach.ai.generation.log',
|
||||||
@@ -39,6 +49,7 @@ MODEL_CONFIG = [
|
|||||||
'text_field': 'brief',
|
'text_field': 'brief',
|
||||||
'description_field': 'generated_content',
|
'description_field': 'generated_content',
|
||||||
'metadata_fields': ['course_type', 'status'],
|
'metadata_fields': ['course_type', 'status'],
|
||||||
|
'taxonomy_field': 'course_type',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'model': 'encoach.chapter.material',
|
'model': 'encoach.chapter.material',
|
||||||
@@ -46,10 +57,21 @@ MODEL_CONFIG = [
|
|||||||
'text_field': 'name',
|
'text_field': 'name',
|
||||||
'description_field': 'description',
|
'description_field': 'description',
|
||||||
'metadata_fields': ['type'],
|
'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):
|
def _get_text(record, config):
|
||||||
"""Extract indexable text from a record."""
|
"""Extract indexable text from a record."""
|
||||||
parts = []
|
parts = []
|
||||||
@@ -69,13 +91,32 @@ def _get_text(record, config):
|
|||||||
|
|
||||||
|
|
||||||
def _get_metadata(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 = {}
|
meta = {}
|
||||||
for f in config.get('metadata_fields', []):
|
for f in config.get('metadata_fields', []):
|
||||||
if hasattr(record, f):
|
if hasattr(record, f):
|
||||||
val = getattr(record, f)
|
val = getattr(record, f)
|
||||||
if val:
|
if val:
|
||||||
meta[f] = str(val) if not isinstance(val, (int, float, bool)) else 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
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
container_name: backend-v2-db
|
container_name: encoach-v4-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: odoo
|
POSTGRES_USER: odoo
|
||||||
@@ -18,7 +18,7 @@ services:
|
|||||||
odoo:
|
odoo:
|
||||||
build: .
|
build: .
|
||||||
image: encoach-backend:latest
|
image: encoach-backend:latest
|
||||||
container_name: backend-v2-odoo
|
container_name: encoach-v4-odoo
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -28,9 +28,15 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- odoo-web-data:/var/lib/odoo
|
- odoo-web-data:/var/lib/odoo
|
||||||
- ./odoo-docker.conf:/etc/odoo/odoo.conf:ro
|
- ./odoo-docker.conf:/etc/odoo/odoo.conf:ro
|
||||||
- ./new_project/custom_addons:/mnt/custom_addons:ro
|
# Mount the whole monorepo so both custom addons and OpenEduCat are
|
||||||
- ./new_project/enterprise-19:/mnt/enterprise:ro
|
# available via /mnt/extra-addons (see odoo-docker.conf).
|
||||||
- ./new_project/openeducat_erp-19.0:/mnt/openeducat:ro
|
- .:/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:
|
volumes:
|
||||||
odoo-db-data:
|
odoo-db-data:
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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
26
docs/adr/0000-template.md
Normal 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.
|
||||||
52
docs/adr/0001-canonical-directory-layout.md
Normal file
52
docs/adr/0001-canonical-directory-layout.md
Normal 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.
|
||||||
66
docs/adr/0002-jwt-refresh-token-flow.md
Normal file
66
docs/adr/0002-jwt-refresh-token-flow.md
Normal 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`.
|
||||||
60
docs/adr/0003-paginated-response-envelope.md
Normal file
60
docs/adr/0003-paginated-response-envelope.md
Normal 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.
|
||||||
67
docs/adr/0004-rag-metadata-and-chunking.md
Normal file
67
docs/adr/0004-rag-metadata-and-chunking.md
Normal 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
22
docs/adr/README.md
Normal 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.
|
||||||
27
frontend/e2e/login.spec.ts
Normal file
27
frontend/e2e/login.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
154
frontend/package-lock.json
generated
154
frontend/package-lock.json
generated
@@ -42,6 +42,8 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.0.6",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-i18next": "^17.0.4",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@testing-library/jest-dom": "^6.6.0",
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
@@ -905,6 +909,22 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -5480,6 +5500,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||||
@@ -5509,6 +5538,46 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -6215,6 +6284,53 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.9",
|
"version": "8.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||||
@@ -6547,6 +6663,33 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -7369,7 +7512,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -8164,6 +8307,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:install": "playwright install --with-deps chromium"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
@@ -47,6 +49,8 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.0.6",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@@ -54,6 +58,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-i18next": "^17.0.4",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -65,9 +70,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@testing-library/jest-dom": "^6.6.0",
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
|
|||||||
42
frontend/playwright.config.ts
Normal file
42
frontend/playwright.config.ts
Normal 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
12
frontend/public/logo.svg
Normal 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 |
@@ -1,3 +1,5 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
@@ -8,146 +10,166 @@ import ProtectedRoute from "@/components/ProtectedRoute";
|
|||||||
import StudentLayout from "@/components/StudentLayout";
|
import StudentLayout from "@/components/StudentLayout";
|
||||||
import TeacherLayout from "@/components/TeacherLayout";
|
import TeacherLayout from "@/components/TeacherLayout";
|
||||||
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
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 { queryClient } from "@/lib/query-client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
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() {
|
function StudentSubscriptionPlaceholder() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
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 = () => (
|
const App = () => (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
storageKey="encoach-theme"
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth routes */}
|
{/* Auth routes */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -200,6 +238,7 @@ const App = () => (
|
|||||||
<Route path="/student/attendance" element={<StudentAttendance />} />
|
<Route path="/student/attendance" element={<StudentAttendance />} />
|
||||||
<Route path="/student/timetable" element={<StudentTimetable />} />
|
<Route path="/student/timetable" element={<StudentTimetable />} />
|
||||||
<Route path="/student/profile" element={<StudentProfile />} />
|
<Route path="/student/profile" element={<StudentProfile />} />
|
||||||
|
<Route path="/student/privacy" element={<PrivacyCenter />} />
|
||||||
<Route path="/student/subjects" element={<SubjectSelection />} />
|
<Route path="/student/subjects" element={<SubjectSelection />} />
|
||||||
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
||||||
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
||||||
@@ -241,6 +280,7 @@ const App = () => (
|
|||||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||||
|
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
|
||||||
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
||||||
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -263,6 +303,7 @@ const App = () => (
|
|||||||
<Route path="/admin/reports" element={<AdminReports />} />
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||||
|
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
||||||
{/* Original academic pages */}
|
{/* Original academic pages */}
|
||||||
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
||||||
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
||||||
@@ -285,13 +326,16 @@ const App = () => (
|
|||||||
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
||||||
<Route path="/admin/tickets" element={<TicketsPage />} />
|
<Route path="/admin/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
||||||
<Route path="/admin/exam" element={<ExamPage />} />
|
|
||||||
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
||||||
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
||||||
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
|
<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/taxonomy" element={<TaxonomyManager />} />
|
||||||
<Route path="/admin/resources" element={<ResourceManager />} />
|
<Route path="/admin/resources" element={<ResourceManager />} />
|
||||||
{/* Institutional LMS pages */}
|
{/* Institutional LMS pages */}
|
||||||
@@ -340,10 +384,12 @@ const App = () => (
|
|||||||
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
164
frontend/src/components/AIFeedbackButtons.tsx
Normal file
164
frontend/src/components/AIFeedbackButtons.tsx
Normal 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;
|
||||||
@@ -9,6 +9,8 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||||
import { usePermissions } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
@@ -29,7 +31,7 @@ import {
|
|||||||
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
||||||
HelpCircle as FaqIcon, Bell, Workflow,
|
HelpCircle as FaqIcon, Bell, Workflow,
|
||||||
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
||||||
Library, Activity, Warehouse, UserCog,
|
Library, Activity, Warehouse, UserCog, Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
@@ -56,6 +58,9 @@ const academicItems: NavItem[] = [
|
|||||||
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
||||||
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||||
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
|
{ 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 },
|
{ 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",
|
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
|
||||||
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
||||||
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
|
"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",
|
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
|
||||||
classrooms: "Classrooms", "student-performance": "Student Performance",
|
classrooms: "Classrooms", "student-performance": "Student Performance",
|
||||||
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
||||||
@@ -218,6 +225,8 @@ export default function AdminLmsLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<AiSearchBar />
|
<AiSearchBar />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageToggle />
|
||||||
|
<ThemeToggle />
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||||
<Ticket className="h-4 w-4" />
|
<Ticket className="h-4 w-4" />
|
||||||
@@ -282,12 +291,18 @@ function AdminSidebar() {
|
|||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
||||||
{!collapsed && (
|
<img
|
||||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
src="/logo.png"
|
||||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
alt="EnCoach"
|
||||||
<span>Coach</span>
|
className="h-9 w-9 shrink-0 rounded-lg object-contain"
|
||||||
</span>
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="EnCoach — Unlock your potential with AI powered platform"
|
||||||
|
className="h-14 w-auto shrink-0 object-contain"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|||||||
43
frontend/src/components/LanguageToggle.tsx
Normal file
43
frontend/src/components/LanguageToggle.tsx
Normal 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;
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
||||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
@@ -83,11 +85,16 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
|||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
<img
|
||||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
src="/logo.png"
|
||||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
alt="EnCoach"
|
||||||
<span>Coach</span>
|
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
|
||||||
</span>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarSeparator />
|
<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>
|
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageToggle />
|
||||||
|
<ThemeToggle />
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
68
frontend/src/components/ThemeToggle.tsx
Normal file
68
frontend/src/components/ThemeToggle.tsx
Normal 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;
|
||||||
@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
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<
|
const AlertDialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
AlertDialogContentProps
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, children, description, ...props }, ref) => {
|
||||||
<AlertDialogPortal>
|
const needsFallback =
|
||||||
<AlertDialogOverlay />
|
!props["aria-describedby"] &&
|
||||||
<AlertDialogPrimitive.Content
|
description === undefined &&
|
||||||
ref={ref}
|
!hasAlertDialogDescription(children);
|
||||||
className={cn(
|
return (
|
||||||
"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",
|
<AlertDialogPortal>
|
||||||
className,
|
<AlertDialogOverlay />
|
||||||
)}
|
<AlertDialogPrimitive.Content
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
className={cn(
|
||||||
</AlertDialogPortal>
|
"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;
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -27,29 +27,86 @@ const DialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
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<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
DialogContentProps
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, description, ...props }, ref) => {
|
||||||
<DialogPortal>
|
const hasExplicitDescribedBy = !!props["aria-describedby"];
|
||||||
<DialogOverlay />
|
const needsFallback =
|
||||||
<DialogPrimitive.Content
|
!hasExplicitDescribedBy &&
|
||||||
ref={ref}
|
description === undefined &&
|
||||||
aria-describedby={props["aria-describedby"] ?? undefined}
|
!hasDialogDescription(children);
|
||||||
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",
|
return (
|
||||||
className,
|
<DialogPortal>
|
||||||
)}
|
<DialogOverlay />
|
||||||
{...props}
|
<DialogPrimitive.Content
|
||||||
>
|
ref={ref}
|
||||||
{children}
|
className={cn(
|
||||||
<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">
|
"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",
|
||||||
<X className="h-4 w-4" />
|
className,
|
||||||
<span className="sr-only">Close</span>
|
)}
|
||||||
</DialogPrimitive.Close>
|
{...props}
|
||||||
</DialogPrimitive.Content>
|
>
|
||||||
</DialogPortal>
|
{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;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -49,21 +49,61 @@ const sheetVariants = cva(
|
|||||||
|
|
||||||
interface SheetContentProps
|
interface SheetContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
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>(
|
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||||
({ side = "right", className, children, ...props }, ref) => (
|
({ side = "right", className, children, description, ...props }, ref) => {
|
||||||
<SheetPortal>
|
const needsFallback =
|
||||||
<SheetOverlay />
|
!props["aria-describedby"] &&
|
||||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
description === undefined &&
|
||||||
{children}
|
!hasSheetDescription(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">
|
return (
|
||||||
<X className="h-4 w-4" />
|
<SheetPortal>
|
||||||
<span className="sr-only">Close</span>
|
<SheetOverlay />
|
||||||
</SheetPrimitive.Close>
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
</SheetPrimitive.Content>
|
{description !== undefined && (
|
||||||
</SheetPortal>
|
<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;
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -4,244 +4,246 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
all: ["users"] as const,
|
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,
|
detail: (id: number) => ["users", id] as const,
|
||||||
},
|
},
|
||||||
entities: {
|
entities: {
|
||||||
all: ["entities"] as const,
|
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,
|
detail: (id: number) => ["entities", id] as const,
|
||||||
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
||||||
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
||||||
},
|
},
|
||||||
exams: {
|
exams: {
|
||||||
all: ["exams"] as const,
|
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,
|
detail: (module: string, id: number) => ["exams", module, id] as const,
|
||||||
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
|
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
|
||||||
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
|
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
|
||||||
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
|
structures: (params?: object) => ["exams", "structures", params] as const,
|
||||||
avatars: ["exams", "avatars"] 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: {
|
assignments: {
|
||||||
all: ["assignments"] as const,
|
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,
|
detail: (id: number) => ["assignments", id] as const,
|
||||||
},
|
},
|
||||||
classrooms: {
|
classrooms: {
|
||||||
all: ["classrooms"] as const,
|
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,
|
detail: (id: number) => ["classrooms", id] as const,
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
|
sessions: (params?: object) => ["stats", "sessions", params] as const,
|
||||||
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
|
stats: (params?: object) => ["stats", "stats", params] as const,
|
||||||
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
|
statistical: (params?: object) => ["stats", "statistical", params] as const,
|
||||||
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
|
performance: (params?: object) => ["stats", "performance", params] as const,
|
||||||
},
|
},
|
||||||
training: {
|
training: {
|
||||||
all: ["training"] as const,
|
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,
|
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
||||||
},
|
},
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
packages: ["subscriptions", "packages"] as const,
|
packages: ["subscriptions", "packages"] as const,
|
||||||
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
|
payments: (params?: object) => ["subscriptions", "payments", params] as const,
|
||||||
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
|
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
all: ["tickets"] as const,
|
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,
|
assigned: ["tickets", "assigned"] as const,
|
||||||
},
|
},
|
||||||
approvals: {
|
approvals: {
|
||||||
all: ["approvals"] as const,
|
all: ["approvals"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
|
list: (params?: object) => ["approvals", "list", params] as const,
|
||||||
},
|
},
|
||||||
taxonomy: {
|
taxonomy: {
|
||||||
subjects: ["taxonomy", "subjects"] as const,
|
subjects: ["taxonomy", "subjects"] as const,
|
||||||
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
||||||
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
||||||
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
|
domains: (params?: object) => ["taxonomy", "domains", params] as const,
|
||||||
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
|
topics: (params?: object) => ["taxonomy", "topics", params] as const,
|
||||||
},
|
},
|
||||||
adaptive: {
|
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,
|
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,
|
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
||||||
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
all: ["resources"] as const,
|
all: ["resources"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
|
list: (params?: object) => ["resources", "list", params] as const,
|
||||||
},
|
},
|
||||||
lms: {
|
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,
|
course: (id: number) => ["lms", "courses", id] as const,
|
||||||
myCourses: ["lms", "my-courses"] as const,
|
myCourses: ["lms", "my-courses"] as const,
|
||||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
students: (params?: object) => ["lms", "students", params] as const,
|
||||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
teachers: (params?: object) => ["lms", "teachers", params] as const,
|
||||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
batches: (params?: object) => ["lms", "batches", params] as const,
|
||||||
batch: (id: number) => ["lms", "batches", id] as const,
|
batch: (id: number) => ["lms", "batches", id] as const,
|
||||||
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
|
timetable: (params?: object) => ["lms", "timetable", params] as const,
|
||||||
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
|
attendance: (params?: object) => ["lms", "attendance", params] as const,
|
||||||
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
|
grades: (params?: object) => ["lms", "grades", params] as const,
|
||||||
},
|
},
|
||||||
analytics: {
|
analytics: {
|
||||||
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
|
student: (params?: object) => ["analytics", "student", params] as const,
|
||||||
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
|
class: (params?: object) => ["analytics", "class", params] as const,
|
||||||
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
|
subject: (params?: object) => ["analytics", "subject", params] as const,
|
||||||
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
|
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
|
||||||
alerts: ["analytics", "alerts"] as const,
|
alerts: ["analytics", "alerts"] as const,
|
||||||
},
|
},
|
||||||
academicYears: {
|
academicYears: {
|
||||||
all: ["academic-years"] as const,
|
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,
|
detail: (id: number) => ["academic-years", id] as const,
|
||||||
},
|
},
|
||||||
academicTerms: {
|
academicTerms: {
|
||||||
all: ["academic-terms"] as const,
|
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,
|
detail: (id: number) => ["academic-terms", id] as const,
|
||||||
},
|
},
|
||||||
departments: {
|
departments: {
|
||||||
all: ["departments"] as const,
|
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,
|
detail: (id: number) => ["departments", id] as const,
|
||||||
},
|
},
|
||||||
admissionRegisters: {
|
admissionRegisters: {
|
||||||
all: ["admission-registers"] as const,
|
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,
|
detail: (id: number) => ["admission-registers", id] as const,
|
||||||
},
|
},
|
||||||
admissions: {
|
admissions: {
|
||||||
all: ["admissions"] as const,
|
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,
|
detail: (id: number) => ["admissions", id] as const,
|
||||||
},
|
},
|
||||||
instExamSessions: {
|
instExamSessions: {
|
||||||
all: ["inst-exam-sessions"] as const,
|
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,
|
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
||||||
},
|
},
|
||||||
examTypes: {
|
examTypes: {
|
||||||
all: ["exam-types"] as const,
|
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: {
|
gradeConfigs: {
|
||||||
all: ["grade-configs"] as const,
|
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: {
|
resultTemplates: {
|
||||||
all: ["result-templates"] as const,
|
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: {
|
marksheets: {
|
||||||
all: ["marksheets"] as const,
|
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,
|
detail: (id: number) => ["marksheets", id] as const,
|
||||||
},
|
},
|
||||||
courseAssignments: {
|
courseAssignments: {
|
||||||
all: ["course-assignments"] as const,
|
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,
|
detail: (id: number) => ["course-assignments", id] as const,
|
||||||
},
|
},
|
||||||
assignmentTypes: {
|
assignmentTypes: {
|
||||||
all: ["assignment-types"] as const,
|
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: {
|
subjectRegistrations: {
|
||||||
all: ["subject-registrations"] as const,
|
all: ["subject-registrations"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
list: (params?: object) => ["subject-registrations", "list", params] as const,
|
||||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
available: (params?: object) => ["subject-registrations", "available", params] as const,
|
||||||
},
|
},
|
||||||
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
||||||
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||||
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
||||||
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
|
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
|
||||||
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
|
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
|
||||||
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
|
announcements: (params?: object) => ["announcements", params] as const,
|
||||||
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
|
messages: (params?: object) => ["messages", params] as const,
|
||||||
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
|
sentMessages: (params?: object) => ["messages", "sent", params] as const,
|
||||||
unreadMessages: ["messages", "unread-count"] 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,
|
unreadNotifications: ["notifications", "unread-count"] as const,
|
||||||
notificationRules: ["notification-rules"] as const,
|
notificationRules: ["notification-rules"] as const,
|
||||||
notificationPreferences: ["notification-preferences"] as const,
|
notificationPreferences: ["notification-preferences"] as const,
|
||||||
faqCategories: (audience?: string) => ["faq", "categories", audience] 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: {
|
studentLeaves: {
|
||||||
all: ["student-leaves"] as const,
|
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: {
|
studentLeaveTypes: {
|
||||||
all: ["student-leave-types"] as const,
|
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: {
|
feesPlans: {
|
||||||
all: ["fees-plans"] as const,
|
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,
|
detail: (id: number) => ["fees-plans", id] as const,
|
||||||
},
|
},
|
||||||
studentFees: {
|
studentFees: {
|
||||||
all: ["student-fees"] as const,
|
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: {
|
feesTerms: {
|
||||||
all: ["fees-terms"] as const,
|
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: {
|
lessons: {
|
||||||
all: ["lessons"] as const,
|
all: ["lessons"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
|
list: (params?: object) => ["lessons", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradebooks: {
|
gradebooks: {
|
||||||
all: ["gradebooks"] as const,
|
all: ["gradebooks"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
|
list: (params?: object) => ["gradebooks", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradebookLines: {
|
gradebookLines: {
|
||||||
all: ["gradebook-lines"] as const,
|
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: {
|
gradingAssignments: {
|
||||||
all: ["grading-assignments"] as const,
|
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: {
|
studentProgress: {
|
||||||
all: ["student-progress"] as const,
|
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,
|
detail: (id: number) => ["student-progress", "detail", id] as const,
|
||||||
},
|
},
|
||||||
libraryMedia: {
|
libraryMedia: {
|
||||||
all: ["library-media"] as const,
|
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: {
|
libraryMovements: {
|
||||||
all: ["library-movements"] as const,
|
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: {
|
libraryCards: {
|
||||||
all: ["library-cards"] as const,
|
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: {
|
activities: {
|
||||||
all: ["activities"] as const,
|
all: ["activities"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
|
list: (params?: object) => ["activities", "list", params] as const,
|
||||||
},
|
},
|
||||||
activityTypes: {
|
activityTypes: {
|
||||||
all: ["activity-types"] as const,
|
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: {
|
facilities: {
|
||||||
all: ["facilities"] as const,
|
all: ["facilities"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
|
list: (params?: object) => ["facilities", "list", params] as const,
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
all: ["assets"] as const,
|
all: ["assets"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
|
list: (params?: object) => ["assets", "list", params] as const,
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
goals: ["signup", "goals"] as const,
|
goals: ["signup", "goals"] as const,
|
||||||
@@ -258,7 +260,7 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
ieltsExam: {
|
ieltsExam: {
|
||||||
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
|
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,
|
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
|
||||||
},
|
},
|
||||||
examSession: {
|
examSession: {
|
||||||
@@ -266,7 +268,7 @@ export const queryKeys = {
|
|||||||
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
||||||
},
|
},
|
||||||
grading: {
|
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,
|
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
|
||||||
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", 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,
|
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
||||||
},
|
},
|
||||||
entityOnboarding: {
|
entityOnboarding: {
|
||||||
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
|
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
|
||||||
},
|
},
|
||||||
adaptiveEngine: {
|
adaptiveEngine: {
|
||||||
dashboard: ["adaptive-engine", "dashboard"] as const,
|
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,
|
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
|
||||||
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
||||||
settings: ["adaptive-engine", "settings"] as const,
|
settings: ["adaptive-engine", "settings"] as const,
|
||||||
@@ -299,7 +301,7 @@ export const queryKeys = {
|
|||||||
current: ["branding", "current"] as const,
|
current: ["branding", "current"] as const,
|
||||||
},
|
},
|
||||||
scoreRelease: {
|
scoreRelease: {
|
||||||
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
|
pending: (params?: object) => ["score-release", "pending", params] as const,
|
||||||
},
|
},
|
||||||
verification: {
|
verification: {
|
||||||
verify: (hash: string) => ["verification", hash] as const,
|
verify: (hash: string) => ["verification", hash] as const,
|
||||||
|
|||||||
70
frontend/src/hooks/queries/useAIFeedback.ts
Normal file
70
frontend/src/hooks/queries/useAIFeedback.ts
Normal 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
52
frontend/src/hooks/queries/useAIPrompts.ts
Normal file
52
frontend/src/hooks/queries/useAIPrompts.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
47
frontend/src/hooks/queries/useExamReview.ts
Normal file
47
frontend/src/hooks/queries/useExamReview.ts
Normal 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) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIeltsValidation(examId: number) {
|
export function useIeltsExamValidation(examId: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.ieltsExam.validation(examId),
|
queryKey: queryKeys.ieltsExam.validation(examId),
|
||||||
queryFn: () => ieltsExamService.validate(examId),
|
queryFn: () => ieltsExamService.validate(examId),
|
||||||
|
|||||||
67
frontend/src/i18n/index.ts
Normal file
67
frontend/src/i18n/index.ts
Normal 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;
|
||||||
85
frontend/src/i18n/locales/ar.ts
Normal file
85
frontend/src/i18n/locales/ar.ts
Normal 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;
|
||||||
97
frontend/src/i18n/locales/en.ts
Normal file
97
frontend/src/i18n/locales/en.ts
Normal 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;
|
||||||
@@ -51,6 +51,14 @@
|
|||||||
--sidebar-accent-foreground: 8 40% 78%;
|
--sidebar-accent-foreground: 8 40% 78%;
|
||||||
--sidebar-border: 240 18% 20%;
|
--sidebar-border: 240 18% 20%;
|
||||||
--sidebar-ring: 8 50% 58%;
|
--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 {
|
.dark {
|
||||||
@@ -99,6 +107,12 @@
|
|||||||
--sidebar-accent-foreground: 8 40% 75%;
|
--sidebar-accent-foreground: 8 40% 75%;
|
||||||
--sidebar-border: 240 18% 13%;
|
--sidebar-border: 240 18% 13%;
|
||||||
--sidebar-ring: 8 50% 55%;
|
--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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(/\/$/, "");
|
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||||
const BASE_URL = API_BASE_URL;
|
const BASE_URL = API_BASE_URL;
|
||||||
|
|
||||||
@@ -21,120 +40,229 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(): string | null {
|
const ACCESS_KEY = "encoach_token";
|
||||||
return localStorage.getItem("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 {
|
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 {
|
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> {
|
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
||||||
const data = await response.json().catch(() => null);
|
// waits on the existing request instead of firing a duplicate.
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
if (response.status === 401) {
|
async function performRefresh(): Promise<boolean> {
|
||||||
const hadToken = !!getToken();
|
const refreshToken = getRefreshToken();
|
||||||
clearToken();
|
if (!refreshToken) return false;
|
||||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
try {
|
||||||
if (hadToken) {
|
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||||
window.location.href = "/login";
|
method: "POST",
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
throw new ApiError(401, response.statusText, data);
|
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> {
|
function refreshOnce(): Promise<boolean> {
|
||||||
const headers: Record<string, string> = {
|
if (!refreshPromise) {
|
||||||
"Content-Type": "application/json",
|
refreshPromise = performRefresh().finally(() => {
|
||||||
...extra,
|
refreshPromise = null;
|
||||||
};
|
});
|
||||||
|
|
||||||
const token = getToken();
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
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;
|
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);
|
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
||||||
if (value !== undefined) {
|
if (rawValue === undefined || rawValue === null) continue;
|
||||||
url.searchParams.set(key, String(value));
|
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();
|
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 = {
|
export const api = {
|
||||||
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path, params), {
|
return performRequest<T>(buildUrl(path, params), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete<T>(path: string): Promise<T> {
|
async delete<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||||
const token = getToken();
|
return performRequest<T>(buildUrl(path), {
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(buildUrl(path), {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers: buildHeaders(undefined, false),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user