Compare commits
14 Commits
c016a52200
...
v4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6f4976c3 | ||
|
|
e2aa8031ff | ||
|
|
1223074bde | ||
|
|
75ee0f1fe0 | ||
|
|
d34180e107 | ||
|
|
eef3edf7e8 | ||
|
|
a554ef5d42 | ||
|
|
bab588b9da | ||
|
|
e1f059069f | ||
|
|
6712d1d551 | ||
|
|
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
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -88,9 +88,13 @@ addons_enterprise/
|
||||
addons_extra/
|
||||
new_project/enterprise-17/
|
||||
|
||||
# Third-party modules (downloaded separately)
|
||||
# Third-party modules
|
||||
# OpenEduCat Community (LGPL-3) is vendored under `backend/openeducat_erp-19.0/`
|
||||
# and must be tracked — `addons_path` in odoo.conf / odoo-docker.conf relies on
|
||||
# it, and database dumps mark 12 openeducat_* modules as installed. Excluding
|
||||
# this folder previously caused restores on the VPS to fail with "module not
|
||||
# found" errors.
|
||||
new_project/openeducat_erp-19.0/
|
||||
backend/openeducat_erp-19.0/
|
||||
new_project/openeducat_erp-19.0.zip
|
||||
new_project/openeducate_enterprise-17.zip
|
||||
new_project/encoach_frontend_new_v1-main.zip
|
||||
@@ -105,3 +109,6 @@ htmlcov/
|
||||
|
||||
# Poetry
|
||||
poetry.lock
|
||||
|
||||
# Odoo DB backups (local only, not source-controlled)
|
||||
backups/
|
||||
|
||||
@@ -3,8 +3,11 @@ FROM odoo:19.0
|
||||
USER root
|
||||
|
||||
# Install all Python dependencies required by EnCoach custom addons.
|
||||
# --ignore-installed: skip uninstalling system packages that lack RECORD files (e.g. Debian's typing_extensions).
|
||||
COPY new_project/requirements.txt /tmp/requirements.txt
|
||||
# --ignore-installed: skip uninstalling system packages that lack RECORD files
|
||||
# (e.g. Debian's typing_extensions).
|
||||
# Canonical source: repo-root requirements.txt (the legacy copy under
|
||||
# new_project/ is no longer authoritative — see new_project/DEPRECATED.md).
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install -r /tmp/requirements.txt --break-system-packages --no-warn-script-location --ignore-installed
|
||||
|
||||
USER odoo
|
||||
|
||||
267
README.md
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 |
|
||||
|-----------|--------|
|
||||
| **Framework** | Odoo 19 Community Edition |
|
||||
| **Language** | Python 3.12+ |
|
||||
| **Database** | PostgreSQL 16 |
|
||||
| **LMS Foundation** | OpenEduCat Community (14 modules, ported to v19) |
|
||||
| **Custom Modules** | 27 `encoach_*` modules |
|
||||
| **AI/ML** | OpenAI GPT-4o, Whisper (local), AWS Polly, ELAI, GPTZero, FAISS |
|
||||
| **Authentication** | JWT (PyJWT) |
|
||||
| **Deployment** | Docker Compose |
|
||||
- **Backend** — Odoo 19 + ~30 `encoach_*` modules (Python 3.12, PostgreSQL 16)
|
||||
- **Frontend** — React 18 + Vite + TypeScript single-page app
|
||||
- **AI layer** — OpenAI + pgvector RAG, quality-gate validation, IELTS validator
|
||||
- **Ops** — Docker Compose, JWT auth with refresh tokens, Prometheus-compatible
|
||||
metrics, dynamic OpenAPI 3.0 spec
|
||||
|
||||
## Module Architecture
|
||||
> **Canonical trees:** `backend/` (all server code) and `frontend/` (all client
|
||||
> code). The legacy `new_project/` directory is deprecated — see
|
||||
> [`new_project/DEPRECATED.md`](new_project/DEPRECATED.md).
|
||||
|
||||
```
|
||||
new_project/
|
||||
custom_addons/ # 27 custom EnCoach modules
|
||||
encoach_api/ # REST API controllers (~120 routes)
|
||||
encoach_lms_api/ # OpenEduCat bridge API (~209 routes)
|
||||
encoach_adaptive_api/ # Adaptive learning API (~41 routes)
|
||||
encoach_resources/ # Resource library API (~7 routes)
|
||||
encoach_core/ # Users, entities, roles, permissions
|
||||
encoach_taxonomy/ # Subject > Domain > Topic taxonomy
|
||||
encoach_adaptive/ # Proficiency, learning plans, mastery
|
||||
encoach_adaptive_ai/ # AI for diagnostics, coaching, content
|
||||
encoach_exam/ # AI-powered exam engine
|
||||
encoach_ai/ # OpenAI service wrapper
|
||||
encoach_ai_generation/ # AI exam content generation
|
||||
encoach_ai_grading/ # AI grading (writing, speaking, math, IT)
|
||||
encoach_ai_media/ # TTS (Polly), STT (Whisper), ELAI avatars
|
||||
encoach_courseware/ # Chapters, materials, AI workbench
|
||||
encoach_communication/ # Discussion boards, announcements, messaging
|
||||
encoach_notification/ # Notification engine, rules, preferences
|
||||
encoach_faq/ # FAQ categories and items
|
||||
encoach_approval/ # Approval workflows, stages
|
||||
encoach_assignment/ # Exam-wrapper assignments
|
||||
encoach_classroom/ # Virtual classroom groups
|
||||
encoach_stats/ # Session tracking, statistics
|
||||
encoach_training/ # Training tips, walkthroughs, FAISS
|
||||
encoach_subscription/ # Packages, Stripe/PayPal/Paymob
|
||||
encoach_registration/ # User registration, batch import
|
||||
encoach_ticket/ # Support tickets
|
||||
encoach_sis/ # UTAS SIS integration
|
||||
encoach_branding/ # Whitelabeling
|
||||
openeducat_erp-19.0/ # 14 OpenEduCat community modules
|
||||
openeducat_core/ # Courses, batches, students, faculty
|
||||
openeducat_timetable/ # Timetable sessions
|
||||
openeducat_attendance/ # Attendance management
|
||||
openeducat_exam/ # Institutional exams, marksheets
|
||||
openeducat_assignment/ # Course assignments, submissions
|
||||
openeducat_admission/ # Admission registers, applications
|
||||
openeducat_classroom/ # Physical classrooms
|
||||
openeducat_facility/ # Facilities and assets
|
||||
openeducat_fees/ # Fee plans, student fees
|
||||
openeducat_library/ # Library media, movements, cards
|
||||
openeducat_activity/ # Student activities
|
||||
openeducat_parent/ # Parent-student relationships
|
||||
openeducat_erp/ # ERP connector
|
||||
theme_web_openeducat/ # Web theme
|
||||
```
|
||||
---
|
||||
|
||||
## Staging
|
||||
## 1. Quickstart
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| **Odoo Backend** | `http://5.189.151.117:8069` |
|
||||
| **React Frontend** | `http://5.189.151.117:3000` |
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
This repo is connected to the staging server via a Git post-receive hook.
|
||||
**All deployment is automatic after code review approval.**
|
||||
|
||||
1. Never push directly to `main` -- branch protection will block it.
|
||||
2. Push your changes to a feature branch (e.g. `feature/full-backend-v1`).
|
||||
3. Open a Pull Request on Gitea targeting `main`.
|
||||
4. Request review from **devops (Talal)**.
|
||||
5. Once approved and merged, the staging server rebuilds and redeploys automatically.
|
||||
|
||||
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`.
|
||||
|
||||
## Local Development
|
||||
|
||||
### Option A: Docker (easiest)
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# Odoo: http://localhost:8069
|
||||
# Frontend: cd frontend && npm install && npm run dev (http://localhost:8080)
|
||||
```
|
||||
|
||||
Open `http://localhost:8069`. First run: create a new database.
|
||||
Create a new Odoo database on first visit, then install the `encoach_api`
|
||||
module to pull in every `encoach_*` dependency.
|
||||
|
||||
Stop: `docker compose down`
|
||||
### Source
|
||||
|
||||
### Option B: Source (venv + PostgreSQL)
|
||||
|
||||
**Prerequisites:** macOS, Homebrew, Python 3.11+, PostgreSQL 15+
|
||||
Prerequisites: Python 3.12, PostgreSQL 16, Node 20, npm 10.
|
||||
|
||||
```bash
|
||||
brew install postgresql@15 wkhtmltopdf
|
||||
brew services start postgresql@15
|
||||
createuser -s $(whoami)
|
||||
# Backend
|
||||
./setup.sh # creates venv, installs requirements.txt
|
||||
./run.sh # starts Odoo on :8069
|
||||
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
./run.sh
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # starts Vite on :8080, proxies /api → :8069
|
||||
```
|
||||
|
||||
Open `http://localhost:8069`.
|
||||
See [`MANUAL-RUN.md`](MANUAL-RUN.md) for a step-by-step walkthrough and
|
||||
[`CONNECT-POSTGRES.md`](CONNECT-POSTGRES.md) for database wiring.
|
||||
|
||||
See [MANUAL-RUN.md](MANUAL-RUN.md) for step-by-step commands.
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
## 2. Repository layout
|
||||
|
||||
```
|
||||
odoo19/
|
||||
├── backend/
|
||||
│ └── custom_addons/
|
||||
│ ├── encoach_api/ REST base + JWT + OpenAPI + metrics
|
||||
│ ├── encoach_core/ Users, entities, roles, permissions
|
||||
│ ├── encoach_taxonomy/ Subject → Domain → Topic
|
||||
│ ├── encoach_ai/ OpenAI wrapper, cefr_mapper, validator
|
||||
│ ├── encoach_ai_course/ AI course/exam generation pipelines
|
||||
│ ├── encoach_ai_grading/ AI grading (writing/speaking/math/IT)
|
||||
│ ├── encoach_ai_media/ TTS (Polly), STT (Whisper), ELAI
|
||||
│ ├── encoach_vector/ pgvector store + RAG embeddings
|
||||
│ ├── encoach_exam_template/ Canonical exam + student attempt models
|
||||
│ ├── encoach_scoring/ Score computation, CEFR mapping
|
||||
│ ├── encoach_quality_gate/ Automated content-quality checks
|
||||
│ ├── encoach_ielts_validation/ IELTS-specific validators
|
||||
│ ├── encoach_adaptive/ Adaptive engine, style matcher
|
||||
│ ├── encoach_lms_api/ OpenEduCat bridge + LMS endpoints
|
||||
│ ├── encoach_branding/ White-label config per entity
|
||||
│ └── … (other encoach_* modules)
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ Route pages (React.lazy code-split)
|
||||
│ │ ├── components/ Shared UI (shadcn/ui + custom)
|
||||
│ │ ├── services/ Thin API wrappers over fetch
|
||||
│ │ ├── hooks/queries/ React Query hooks + keys
|
||||
│ │ ├── lib/api-client.ts Fetch + auto JWT refresh
|
||||
│ │ └── types/ Shared DTO types
|
||||
│ └── vite.config.ts manualChunks: react, query, charts, radix…
|
||||
├── docs/
|
||||
│ ├── PROJECT_SUMMARY.md Release notes & architecture history
|
||||
│ ├── adr/ Architecture Decision Records
|
||||
│ └── ENCOACH_*.md SRS, workflows, user stories
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── requirements.txt Python deps (pgvector, textstat, etc.)
|
||||
└── README.md You are here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture at a glance
|
||||
|
||||
```
|
||||
┌────────────────────┐ HTTPS/JWT ┌────────────────────┐
|
||||
│ React SPA (Vite) │ ───────────────► │ Odoo 19 + FastAPI-style │
|
||||
│ - React Query │ ◄─────────────── │ controllers (encoach_*) │
|
||||
│ - next-themes │ X-Request-Id └──────┬─────────────┘
|
||||
│ - shadcn/ui │ │
|
||||
└────────────────────┘ ▼
|
||||
┌──────────────┐
|
||||
│ PostgreSQL │
|
||||
│ + pgvector │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ OpenAI, │
|
||||
│ Whisper, │
|
||||
│ Polly… │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
Every request carries an `X-Request-Id` and emits structured JSON logs.
|
||||
Prometheus-compatible counters are exposed at `/api/metrics`, and the live
|
||||
OpenAPI 3.0 spec is at `/api/openapi.json`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key conventions
|
||||
|
||||
- **Canonical response envelope** — list endpoints return
|
||||
`{ items: T[], total, page, size, data: T[] }` (see `encoach_api.controllers.base.paginated_envelope`).
|
||||
- **CEFR mapping** — only `encoach_ai.services.cefr_mapper` is canonical. Do
|
||||
not reintroduce local `band_to_cefr` copies.
|
||||
- **JWT tokens** — short-lived access tokens (1h) + revocable refresh tokens
|
||||
(7d). Only `access` tokens are accepted as Bearer credentials; refresh tokens
|
||||
must go through `/api/auth/refresh`. See [`docs/adr/0002-jwt-refresh-token-flow.md`](docs/adr/0002-jwt-refresh-token-flow.md).
|
||||
- **RAG metadata** — vector embeddings carry `course_id`, `subject_id`,
|
||||
`entity_id`, `taxonomy`, `content_hash`. Chunking kicks in above 2 000 chars.
|
||||
- **Frontend pagination** — `PaginatedResponse<T>` exposes both `items` and
|
||||
`data`. Read from `items` in new code.
|
||||
- **Frontend theming** — tokens live in `frontend/src/index.css` (`:root` and
|
||||
`.dark`). Always use `hsl(var(--token))` instead of raw hex.
|
||||
|
||||
---
|
||||
|
||||
## 5. Health, observability, docs
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/health` | Liveness (always 200 when server is up) |
|
||||
| `GET /api/health/ready` | Readiness (DB + required config) |
|
||||
| `GET /api/openapi.json` | Dynamic OpenAPI 3.0 spec generated from `@http.route` |
|
||||
| `GET /api/metrics` | Prometheus-format counters per route |
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment
|
||||
|
||||
Staging and production both use Docker Compose. The staging server rebuilds
|
||||
automatically from `main`; never force-push. See
|
||||
[`INSTALL-ODOO-SUMMARY.md`](INSTALL-ODOO-SUMMARY.md) for bootstrap notes.
|
||||
|
||||
| Service | Staging URL |
|
||||
|---|---|
|
||||
| Odoo backend | `http://5.189.151.117:8069` |
|
||||
| React frontend | `http://5.189.151.117:3000` |
|
||||
|
||||
The `.env` file is **never** committed. On staging it lives at
|
||||
`/opt/encoach/backend-v2/.env`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Further reading
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| `ENCOACH_ODOO19_BACKEND_SRS.md` | Backend SRS v3.0 (definitive) |
|
||||
| `ENCOACH_UNIFIED_SRS.md` | Unified frontend + backend SRS v2.0 |
|
||||
| `ENCOACH_PRODUCT_DESCRIPTION.md` | Non-technical product description |
|
||||
|---|---|
|
||||
| [`docs/PROJECT_SUMMARY.md`](docs/PROJECT_SUMMARY.md) | Release notes + architecture history |
|
||||
| [`docs/adr/`](docs/adr/) | Architecture Decision Records (why we built it this way) |
|
||||
| [`docs/ENCOACH_UNIFIED_SRS.md`](docs/ENCOACH_UNIFIED_SRS.md) | Unified frontend + backend SRS |
|
||||
| [`docs/ENCOACH_ODOO19_BACKEND_SRS.md`](docs/ENCOACH_ODOO19_BACKEND_SRS.md) | Backend SRS v3.0 |
|
||||
| [`docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md`](docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md) | Backend workflows |
|
||||
| [`docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md`](docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md) | Frontend workflows |
|
||||
|
||||
---
|
||||
|
||||
## 8. Contributing
|
||||
|
||||
1. Branch from `main` — never push direct. Branch protection enforces it.
|
||||
2. Run `npx tsc --noEmit -p tsconfig.app.json` (frontend) and the module smoke
|
||||
tests before opening a PR.
|
||||
3. Every architectural decision should be captured as an ADR under
|
||||
[`docs/adr/`](docs/adr/). Copy `0000-template.md` to start one.
|
||||
4. Open the PR against `main` and request review from devops (Talal).
|
||||
|
||||
BIN
backend/GE1 Course Outline_ Fall AY25-26.pdf
Normal file
BIN
backend/GE1 Course Outline_ Fall AY25-26.pdf
Normal file
Binary file not shown.
@@ -15,14 +15,15 @@
|
||||
- AI Search, Insights, Report Narrative
|
||||
""",
|
||||
"author": "EnCoach",
|
||||
"depends": ["base", "encoach_core"],
|
||||
"depends": ["base", "encoach_core", "encoach_api"],
|
||||
"external_dependencies": {
|
||||
"python": ["openai", "boto3"],
|
||||
"python": ["openai", "boto3", "langgraph", "langchain_core"],
|
||||
},
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"views/ai_settings_views.xml",
|
||||
"data/ai_defaults.xml",
|
||||
"data/agents_defaults.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": True,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from . import ai_controller
|
||||
from . import coach_controller
|
||||
from . import media_controller
|
||||
from . import prompt_controller
|
||||
from . import feedback_controller
|
||||
from . import agents_controller
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Admin endpoints for configuring and exercising AI agents.
|
||||
|
||||
Design notes:
|
||||
|
||||
* Read endpoints require a valid JWT but not admin. The "AI Agents"
|
||||
tab needs to be reachable by anyone who can see ``/admin/ai/prompts``
|
||||
today (analysts, teachers auditing prompt changes, etc.).
|
||||
* Write endpoints — ``PATCH /api/ai/agents/<id>`` and
|
||||
``POST /api/ai/agents/<id>/test`` — additionally require admin
|
||||
privileges (``base.group_system``), matching the existing prompt
|
||||
controller's policy.
|
||||
* ``/test`` is deliberately synchronous and uncached: admins use it to
|
||||
quickly verify a config change produces sane output. It caps the
|
||||
LLM at 500 tokens to keep iteration cheap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
_json_response,
|
||||
jwt_required,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _require_admin():
|
||||
if not request.env.user.has_group("base.group_system"):
|
||||
return _error_response("Admin privileges required", 403)
|
||||
return None
|
||||
|
||||
|
||||
class EncoachAIAgentsController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/agents
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/api/ai/agents", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def list_agents(self, **kw):
|
||||
try:
|
||||
search = (kw.get("search") or "").strip()
|
||||
domain = []
|
||||
if search:
|
||||
domain = [
|
||||
"|", "|",
|
||||
("key", "ilike", search),
|
||||
("name", "ilike", search),
|
||||
("description", "ilike", search),
|
||||
]
|
||||
Agent = request.env["encoach.ai.agent"].sudo()
|
||||
records = Agent.search(domain, order="sequence, name")
|
||||
items = [r.to_api_dict(include_prompt=False) for r in records]
|
||||
return _json_response({
|
||||
"items": items,
|
||||
"data": items,
|
||||
"total": len(items),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception("list agents failed")
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/agents/<id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/<int:agent_id>",
|
||||
type="http", auth="none", methods=["GET"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def get_agent(self, agent_id, **kw):
|
||||
try:
|
||||
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
|
||||
if not agent.exists():
|
||||
return _error_response("Agent not found", 404)
|
||||
data = agent.to_api_dict(include_prompt=True)
|
||||
data["tools"] = [t.to_api_dict() for t in agent.tool_ids]
|
||||
return _json_response(data)
|
||||
except Exception as exc:
|
||||
_logger.exception("get agent failed")
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/ai/agents/<id> (admin-only)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/<int:agent_id>",
|
||||
type="http", auth="none", methods=["PATCH", "PUT"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def update_agent(self, agent_id, **kw):
|
||||
err = _require_admin()
|
||||
if err is not None:
|
||||
return err
|
||||
try:
|
||||
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
|
||||
if not agent.exists():
|
||||
return _error_response("Agent not found", 404)
|
||||
body = _get_json_body() or {}
|
||||
vals: dict = {}
|
||||
# Whitelist every settable field so callers can't flip `active` or
|
||||
# rewrite `key` without knowing they're allowed to.
|
||||
for f in (
|
||||
"name", "description", "system_prompt", "prompt_key",
|
||||
"model", "fallback_model", "response_format",
|
||||
"graph_type", "quality_checks",
|
||||
):
|
||||
if f in body:
|
||||
vals[f] = body[f] or ""
|
||||
for f in ("temperature",):
|
||||
if f in body:
|
||||
try:
|
||||
vals[f] = float(body[f])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for f in ("max_tokens", "max_revisions", "sequence"):
|
||||
if f in body:
|
||||
try:
|
||||
vals[f] = int(body[f])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "active" in body:
|
||||
vals["active"] = bool(body["active"])
|
||||
if "tool_keys" in body and isinstance(body["tool_keys"], list):
|
||||
tool_ids = request.env["encoach.ai.tool"].sudo().search(
|
||||
[("key", "in", [str(k) for k in body["tool_keys"]])]
|
||||
).ids
|
||||
vals["tool_ids"] = [(6, 0, tool_ids)]
|
||||
with request.env.cr.savepoint():
|
||||
agent.write(vals)
|
||||
return _json_response(agent.to_api_dict(include_prompt=True))
|
||||
except Exception as exc:
|
||||
_logger.exception("update agent failed")
|
||||
return _error_response(str(exc), 400)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/agents/<id>/test (admin-only)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/<int:agent_id>/test",
|
||||
type="http", auth="none", methods=["POST"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def test_agent(self, agent_id, **kw):
|
||||
err = _require_admin()
|
||||
if err is not None:
|
||||
return err
|
||||
try:
|
||||
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
|
||||
if not agent.exists():
|
||||
return _error_response("Agent not found", 404)
|
||||
body = _get_json_body() or {}
|
||||
variables = body.get("variables") or {}
|
||||
payload = body.get("payload")
|
||||
language = body.get("language") or request.env.user.lang or "en"
|
||||
|
||||
from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
|
||||
runtime = AgentRuntime(request.env, agent, language=language)
|
||||
# Small-budget test: cap max_tokens so iteration stays cheap.
|
||||
original_max = agent.max_tokens
|
||||
if original_max > 800:
|
||||
agent.sudo().write({"max_tokens": 800})
|
||||
try:
|
||||
final = runtime.invoke(variables=variables, payload=payload)
|
||||
finally:
|
||||
if agent.max_tokens != original_max:
|
||||
agent.sudo().write({"max_tokens": original_max})
|
||||
|
||||
output = final.get("output")
|
||||
return _json_response({
|
||||
"error": final.get("error") or "",
|
||||
"output": output,
|
||||
"output_raw": (final.get("output_raw") or "")[:6000],
|
||||
"tool_results": (final.get("tool_results") or [])[:20],
|
||||
"retrieval_hits": len(final.get("retrieval") or []),
|
||||
"revisions_used": final.get("revisions_used") or 0,
|
||||
"quality_issues": final.get("quality_issues") or [],
|
||||
"iterations": final.get("iterations") or 0,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception("test agent failed")
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/agents/tools
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/tools",
|
||||
type="http", auth="none", methods=["GET"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def list_tools(self, **kw):
|
||||
try:
|
||||
tools = request.env["encoach.ai.tool"].sudo().search([], order="category, sequence, name")
|
||||
items = [t.to_api_dict() for t in tools]
|
||||
return _json_response({"items": items, "data": items, "total": len(items)})
|
||||
except Exception as exc:
|
||||
_logger.exception("list tools failed")
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/ai/agents/tools/<id> (admin-only; currently toggle active)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/tools/<int:tool_id>",
|
||||
type="http", auth="none", methods=["PATCH", "PUT"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def update_tool(self, tool_id, **kw):
|
||||
err = _require_admin()
|
||||
if err is not None:
|
||||
return err
|
||||
try:
|
||||
tool = request.env["encoach.ai.tool"].sudo().browse(int(tool_id))
|
||||
if not tool.exists():
|
||||
return _error_response("Tool not found", 404)
|
||||
body = _get_json_body() or {}
|
||||
vals: dict = {}
|
||||
if "active" in body:
|
||||
vals["active"] = bool(body["active"])
|
||||
for f in ("name", "description", "category"):
|
||||
if f in body:
|
||||
vals[f] = body[f] or ""
|
||||
if "schema" in body:
|
||||
# Accept a parsed dict OR raw JSON string.
|
||||
raw = body["schema"]
|
||||
if isinstance(raw, (dict, list)):
|
||||
vals["schema_json"] = json.dumps(raw)
|
||||
else:
|
||||
vals["schema_json"] = str(raw)
|
||||
with request.env.cr.savepoint():
|
||||
tool.write(vals)
|
||||
return _json_response(tool.to_api_dict())
|
||||
except Exception as exc:
|
||||
_logger.exception("update tool failed")
|
||||
return _error_response(str(exc), 400)
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo import fields, http
|
||||
from odoo.http import request, Response
|
||||
from odoo.addons.encoach_api.controllers.base import jwt_required
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,11 +24,31 @@ def _get_json():
|
||||
return {}
|
||||
|
||||
|
||||
def _request_language():
|
||||
"""Return the caller's UI language from ``Accept-Language``.
|
||||
|
||||
The frontend ``api-client`` forwards the active i18n language (e.g. ``ar``
|
||||
or ``en``) via this header so AI-generated natural-language strings can
|
||||
be returned in the same language as the UI chrome.
|
||||
"""
|
||||
try:
|
||||
return request.httprequest.headers.get("Accept-Language", "") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _openai_for_request():
|
||||
"""Construct an OpenAIService bound to the caller's UI language."""
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
return OpenAIService(request.env, language=_request_language())
|
||||
|
||||
|
||||
class AIController(http.Controller):
|
||||
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
||||
|
||||
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
|
||||
@http.route("/api/ai/search", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/search", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_search(self, **kw):
|
||||
body = _get_json()
|
||||
query = body.get("query", "")
|
||||
@@ -35,7 +56,7 @@ class AIController(http.Controller):
|
||||
return _json_response({"answer": "", "suggestions": []})
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
result = ai.search_with_rag(query, context=body.get("context", ""))
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
@@ -43,7 +64,8 @@ class AIController(http.Controller):
|
||||
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
|
||||
|
||||
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
|
||||
@http.route("/api/ai/vector-search", type="http", auth="public", methods=["GET"], csrf=False)
|
||||
@http.route("/api/ai/vector-search", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_vector_search(self, **kw):
|
||||
query = request.params.get("q", "")
|
||||
content_type = request.params.get("content_type")
|
||||
@@ -60,12 +82,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"results": [], "query": query, "error": str(e)})
|
||||
|
||||
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
|
||||
@http.route("/api/ai/insights", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/insights", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_insights(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
result = ai.generate_insights(
|
||||
body.get("data", {}),
|
||||
insight_type=body.get("type", "general"),
|
||||
@@ -76,11 +99,12 @@ class AIController(http.Controller):
|
||||
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
|
||||
|
||||
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
|
||||
@http.route("/api/ai/alerts", type="http", auth="public", methods=["GET"], csrf=False)
|
||||
@http.route("/api/ai/alerts", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_alerts(self, **kw):
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
context = request.params.get("context", "dashboard")
|
||||
result = ai.generate_insights(
|
||||
{"context": context, "request": "alerts"},
|
||||
@@ -92,12 +116,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"alerts": []})
|
||||
|
||||
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
|
||||
@http.route("/api/ai/report-narrative", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/report-narrative", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_report_narrative(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
narrative = ai.generate_report_narrative(
|
||||
body.get("report_type", "performance"),
|
||||
body.get("data", {}),
|
||||
@@ -107,12 +132,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"narrative": f"Report generation unavailable: {e}"})
|
||||
|
||||
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
|
||||
@http.route("/api/ai/batch-optimize", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/batch-optimize", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_batch_optimize(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
result = ai.batch_optimize(
|
||||
body.get("items", []),
|
||||
optimization_type=body.get("type", "schedule"),
|
||||
@@ -122,12 +148,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
|
||||
|
||||
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
|
||||
@http.route("/api/ai/grade-suggest", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/grade-suggest", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_grade_suggest(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
skill = body.get("skill", "writing")
|
||||
if skill == "speaking":
|
||||
result = ai.grade_speaking(
|
||||
@@ -146,12 +173,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
|
||||
|
||||
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
|
||||
@http.route("/api/ai/generate-resource", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/generate-resource", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_generate_resource(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
result = ai.generate_content_dedup(
|
||||
body.get("content_type", "reading_passage"),
|
||||
body.get("brief", {}),
|
||||
@@ -162,7 +190,8 @@ class AIController(http.Controller):
|
||||
return _json_response({"resource": None, "status": "error", "error": str(e)})
|
||||
|
||||
# ── POST /api/ai/detect — GPTZero AI detection ──
|
||||
@http.route("/api/ai/detect", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/detect", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_detect(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -174,7 +203,8 @@ class AIController(http.Controller):
|
||||
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
|
||||
|
||||
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
|
||||
@http.route("/api/plagiarism/check", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/plagiarism/check", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def plagiarism_check(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -187,12 +217,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"report_id": None, "error": str(e)})
|
||||
|
||||
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
|
||||
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_suggest_topics(self, domain_id, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
|
||||
@@ -206,12 +237,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"topics": [], "error": str(e)})
|
||||
|
||||
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
|
||||
@http.route("/api/learning-plan/generate", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/learning-plan/generate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def learning_plan_generate(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Create a personalized learning plan. Return JSON: "
|
||||
@@ -227,12 +259,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"plan": None, "error": str(e)})
|
||||
|
||||
# ── Workbench endpoints — AiWorkbench.tsx ──
|
||||
@http.route("/api/workbench/generate-outline", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/workbench/generate-outline", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def workbench_outline(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Generate a course outline. Return JSON: {\"chapters\": "
|
||||
@@ -244,12 +277,13 @@ class AIController(http.Controller):
|
||||
except Exception as e:
|
||||
return _json_response({"chapters": [], "error": str(e)})
|
||||
|
||||
@http.route("/api/workbench/generate-chapter", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/workbench/generate-chapter", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def workbench_chapter(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Generate detailed chapter content for a course. Return JSON: "
|
||||
@@ -262,12 +296,13 @@ class AIController(http.Controller):
|
||||
except Exception as e:
|
||||
return _json_response({"content": "", "error": str(e)})
|
||||
|
||||
@http.route("/api/workbench/generate-rubric", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/workbench/generate-rubric", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def workbench_rubric(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
||||
@@ -280,11 +315,13 @@ class AIController(http.Controller):
|
||||
except Exception as e:
|
||||
return _json_response({"rubric": None, "error": str(e)})
|
||||
|
||||
@http.route("/api/workbench/regenerate", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/workbench/regenerate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def workbench_regenerate(self, **kw):
|
||||
return self.workbench_chapter(**kw)
|
||||
|
||||
@http.route("/api/workbench/publish", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/workbench/publish", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def workbench_publish(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -315,7 +352,8 @@ class AIController(http.Controller):
|
||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
|
||||
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_suggest_rubric_criteria(self, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
@@ -331,7 +369,7 @@ class AIController(http.Controller):
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
if not ai.client:
|
||||
raise RuntimeError("OpenAI not configured")
|
||||
|
||||
@@ -450,7 +488,8 @@ class AIController(http.Controller):
|
||||
]
|
||||
|
||||
# ── Exam generation — GenerationPage.tsx ──
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def exam_generate(self, module, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
@@ -460,7 +499,7 @@ class AIController(http.Controller):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
has_ai = bool(ai.client)
|
||||
except Exception:
|
||||
ai, has_ai = None, False
|
||||
@@ -1252,7 +1291,8 @@ class AIController(http.Controller):
|
||||
return {"questions": questions}
|
||||
|
||||
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def generation_submit(self, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
@@ -1265,12 +1305,40 @@ class AIController(http.Controller):
|
||||
if not title:
|
||||
return _json_response({"error": "title is required"}, 400)
|
||||
|
||||
from odoo.addons.encoach_ai.services.question_validator import (
|
||||
validate_payload, score_question, summarize_question_reports,
|
||||
dumps_report, prompt_hash as _prompt_hash,
|
||||
VERDICT_FAIL, VERDICT_WARN,
|
||||
)
|
||||
|
||||
schema_report = validate_payload(body)
|
||||
if schema_report["verdict"] == VERDICT_FAIL:
|
||||
_logger.warning(
|
||||
"generation_submit rejected %d schema errors: %s",
|
||||
len(schema_report["errors"]), schema_report["errors"][:3],
|
||||
)
|
||||
return _json_response({
|
||||
"error": "AI payload failed schema validation",
|
||||
"details": schema_report,
|
||||
}, 422)
|
||||
|
||||
label = body.get("label", "")
|
||||
modules = body.get("modules", {})
|
||||
skip_approval = body.get("skip_approval", False)
|
||||
exam_mode = body.get("exam_mode", "official")
|
||||
structure_id = body.get("structure_id")
|
||||
|
||||
ai_model_used = (
|
||||
body.get("ai_model")
|
||||
or request.env["ir.config_parameter"].sudo().get_param(
|
||||
"encoach_ai.openai_fast_model", "gpt-4o-mini"
|
||||
)
|
||||
)
|
||||
payload_hash = _prompt_hash(json.dumps(
|
||||
{"title": title, "modules": modules}, sort_keys=True, default=str,
|
||||
))
|
||||
question_reports = [] # collected for aggregate gating below
|
||||
|
||||
first_mod = next(iter(modules.values()), {}) if modules else {}
|
||||
entity_val = first_mod.get("entity", "none")
|
||||
entity_id = int(entity_val) if entity_val and entity_val != "none" else False
|
||||
@@ -1304,13 +1372,14 @@ class AIController(http.Controller):
|
||||
except KeyError:
|
||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||
|
||||
initial_status = "published" if skip_approval else "pending_approval"
|
||||
exam_vals = {
|
||||
"title": title,
|
||||
"label": label,
|
||||
"exam_mode": exam_mode,
|
||||
"teacher_id": request.env.user.id,
|
||||
"template_id": template_id,
|
||||
"status": "published" if skip_approval else "draft",
|
||||
"status": initial_status,
|
||||
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
|
||||
"total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()),
|
||||
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
|
||||
@@ -1344,6 +1413,27 @@ class AIController(http.Controller):
|
||||
"form_completion": "form_completion", "map_labelling": "map_labelling",
|
||||
}
|
||||
|
||||
now = fields.Datetime.now() if hasattr(fields, 'Datetime') else None
|
||||
|
||||
def _create_question(vals, *, cefr_level=None):
|
||||
stem = vals.get("stem") or ""
|
||||
q_report = score_question(
|
||||
stem, skill=vals.get("skill"), cefr_level=cefr_level
|
||||
)
|
||||
question_reports.append(q_report)
|
||||
status = vals.pop("status", "draft")
|
||||
if q_report["verdict"] == VERDICT_FAIL:
|
||||
status = "flagged"
|
||||
vals.update({
|
||||
"status": status,
|
||||
"ai_model_used": ai_model_used,
|
||||
"ai_prompt_hash": payload_hash,
|
||||
"ai_generated_at": now,
|
||||
"quality_score": q_report.get("score"),
|
||||
"quality_report": dumps_report(q_report),
|
||||
})
|
||||
return Question.create(vals)
|
||||
|
||||
seq = 10
|
||||
total_questions = 0
|
||||
for mod_key, mod_data in modules.items():
|
||||
@@ -1381,7 +1471,7 @@ class AIController(http.Controller):
|
||||
for ex in (passage.get("exercises") or []):
|
||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||
opts = ex.get("options", [])
|
||||
q = Question.create({
|
||||
q = _create_question({
|
||||
"skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading",
|
||||
"source_type": "passage",
|
||||
"question_type": q_type,
|
||||
@@ -1392,7 +1482,7 @@ class AIController(http.Controller):
|
||||
"difficulty": _q_difficulty_for(ex),
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
}, cefr_level=(ex.get("cefr_level") or cefr_level))
|
||||
question_ids.append(q.id)
|
||||
|
||||
sections_data = mod_data.get("sections") or []
|
||||
@@ -1402,7 +1492,7 @@ class AIController(http.Controller):
|
||||
for ex in (s_data.get("exercises") or []):
|
||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||
opts = ex.get("options", [])
|
||||
q = Question.create({
|
||||
q = _create_question({
|
||||
"skill": "listening",
|
||||
"source_type": "audio",
|
||||
"question_type": q_type,
|
||||
@@ -1413,12 +1503,12 @@ class AIController(http.Controller):
|
||||
"difficulty": _q_difficulty_for(ex),
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
}, cefr_level=(ex.get("cefr_level") or cefr_level))
|
||||
question_ids.append(q.id)
|
||||
|
||||
tasks = mod_data.get("tasks") or []
|
||||
for t_idx, task in enumerate(tasks):
|
||||
q = Question.create({
|
||||
q = _create_question({
|
||||
"skill": "writing",
|
||||
"source_type": "writing_prompt",
|
||||
"question_type": "short_answer",
|
||||
@@ -1429,12 +1519,12 @@ class AIController(http.Controller):
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
}, cefr_level=cefr_level)
|
||||
question_ids.append(q.id)
|
||||
|
||||
parts = mod_data.get("parts") or []
|
||||
for p_idx, part in enumerate(parts):
|
||||
q = Question.create({
|
||||
q = _create_question({
|
||||
"skill": "speaking",
|
||||
"source_type": "speaking_card",
|
||||
"question_type": "short_answer",
|
||||
@@ -1445,7 +1535,7 @@ class AIController(http.Controller):
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
}, cefr_level=cefr_level)
|
||||
question_ids.append(q.id)
|
||||
|
||||
if question_ids:
|
||||
@@ -1455,18 +1545,72 @@ class AIController(http.Controller):
|
||||
})
|
||||
total_questions += len(question_ids)
|
||||
|
||||
quality_summary = summarize_question_reports(question_reports)
|
||||
if (
|
||||
exam.status != "draft"
|
||||
and quality_summary["verdict"] in (VERDICT_FAIL, VERDICT_WARN)
|
||||
and quality_summary["total"] > 0
|
||||
):
|
||||
exam.sudo().write({"status": "pending_review"})
|
||||
_logger.info(
|
||||
"exam %s forced pending_review: avg_score=%.2f failed=%d warned=%d",
|
||||
exam.id, quality_summary["avg_score"],
|
||||
quality_summary["failed"], quality_summary["warned"],
|
||||
)
|
||||
|
||||
# ── Route through the approval workflow ────────────────────────
|
||||
# If the admin selected an approval workflow AND did not click
|
||||
# "skip approval", create a real ``encoach.approval.request`` that
|
||||
# lands in the first stage approver's queue. QA flagged that
|
||||
# submitted modules were invisible to the assigned approver —
|
||||
# before this block the exam was just left in ``draft`` with
|
||||
# ``approval_workflow_id`` set but no request record routing it.
|
||||
approval_request_id = False
|
||||
if not skip_approval and workflow_id:
|
||||
try:
|
||||
Workflow = request.env["encoach.approval.workflow"].sudo()
|
||||
workflow = Workflow.browse(workflow_id)
|
||||
if workflow.exists() and workflow.stage_ids:
|
||||
first_stage = workflow.stage_ids.sorted("sequence")[:1]
|
||||
approval_req = request.env["encoach.approval.request"].sudo().create({
|
||||
"workflow_id": workflow.id,
|
||||
"res_model": "encoach.exam.custom",
|
||||
"res_id": exam.id,
|
||||
"state": "in_progress" if first_stage else "draft",
|
||||
"requester_id": request.env.user.id,
|
||||
"current_stage_id": first_stage.id if first_stage else False,
|
||||
})
|
||||
approval_request_id = approval_req.id
|
||||
_logger.info(
|
||||
"created approval.request %s for exam %s (workflow %s, stage %s)",
|
||||
approval_req.id, exam.id, workflow.id,
|
||||
first_stage.id if first_stage else None,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"failed to create approval request for exam %s workflow %s",
|
||||
exam.id, workflow_id,
|
||||
)
|
||||
|
||||
return _json_response({
|
||||
"exam_id": exam.id,
|
||||
"status": exam.status,
|
||||
"template_id": template_id,
|
||||
"total_questions": total_questions,
|
||||
"approval_request_id": approval_request_id,
|
||||
"quality": quality_summary,
|
||||
"schema_validation": {
|
||||
"verdict": schema_report["verdict"],
|
||||
"warnings": schema_report["warnings"],
|
||||
},
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception("generation submit failed")
|
||||
return _json_response({"error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
|
||||
@http.route("/api/ai/batch-optimize/apply", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/ai/batch-optimize/apply", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_batch_optimize_apply(self, **kw):
|
||||
body = _get_json()
|
||||
optimized = body.get("optimized", [])
|
||||
@@ -1481,7 +1625,8 @@ class AIController(http.Controller):
|
||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def exam_generate_save(self, module, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
@@ -1521,12 +1666,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"saved": 0, "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
|
||||
@http.route("/api/workbench/suggest-materials", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/workbench/suggest-materials", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def workbench_suggest_materials(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"You are an educational materials expert. Suggest learning materials "
|
||||
@@ -1541,12 +1687,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"materials": [], "error": str(e)})
|
||||
|
||||
# ── Topic content generation — adaptive ──
|
||||
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def topic_generate_content(self, topic_id, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
ai = OpenAIService(request.env, language=_request_language())
|
||||
result = ai.generate_content(
|
||||
body.get("content_type", "explanation"),
|
||||
{"topic_id": topic_id, **body},
|
||||
|
||||
@@ -4,6 +4,10 @@ import json
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response as _base_json_response, _get_json_body,
|
||||
)
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,15 +23,29 @@ def _get_json():
|
||||
return {}
|
||||
|
||||
|
||||
def _request_language():
|
||||
"""Read the caller's UI language from the ``Accept-Language`` header.
|
||||
|
||||
The frontend ``api-client`` automatically attaches this header from the
|
||||
active i18n language so AI-generated text can be localized. Falls back
|
||||
to English if the header is missing or malformed.
|
||||
"""
|
||||
try:
|
||||
return request.httprequest.headers.get("Accept-Language", "") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
class CoachController(http.Controller):
|
||||
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
|
||||
|
||||
def _get_coach(self):
|
||||
from odoo.addons.encoach_ai.services.coach_service import CoachService
|
||||
return CoachService(request.env)
|
||||
return CoachService(request.env, language=_request_language())
|
||||
|
||||
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
|
||||
@http.route("/api/coach/chat", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def coach_chat(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -43,7 +61,8 @@ class CoachController(http.Controller):
|
||||
return _json_response({"reply": f"I'm having trouble right now. Error: {e}"})
|
||||
|
||||
# ── GET /api/coach/tip — AiTipBanner.tsx ──
|
||||
@http.route("/api/coach/tip", type="http", auth="public", methods=["GET"], csrf=False)
|
||||
@http.route("/api/coach/tip", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def coach_tip(self, **kw):
|
||||
context = request.params.get("context", "general")
|
||||
try:
|
||||
@@ -53,7 +72,8 @@ class CoachController(http.Controller):
|
||||
return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"})
|
||||
|
||||
# ── POST /api/coach/explain — AiGradeExplainer.tsx ──
|
||||
@http.route("/api/coach/explain", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/coach/explain", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def coach_explain(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -67,7 +87,8 @@ class CoachController(http.Controller):
|
||||
return _json_response({"explanation": f"Could not generate explanation: {e}"})
|
||||
|
||||
# ── POST /api/coach/suggest — AiStudyCoach.tsx ──
|
||||
@http.route("/api/coach/suggest", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/coach/suggest", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def coach_suggest(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -82,7 +103,8 @@ class CoachController(http.Controller):
|
||||
})
|
||||
|
||||
# ── POST /api/coach/writing-help — AiWritingHelper.tsx ──
|
||||
@http.route("/api/coach/writing-help", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/coach/writing-help", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def coach_writing_help(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -97,7 +119,8 @@ class CoachController(http.Controller):
|
||||
return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]})
|
||||
|
||||
# ── POST /api/coach/hint — (unused component, wired for completeness) ──
|
||||
@http.route("/api/coach/hint", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/coach/hint", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def coach_hint(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response as _base_json_response, _get_json_body,
|
||||
)
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,7 +56,8 @@ class MediaController(http.Controller):
|
||||
)
|
||||
|
||||
# ── POST /api/exam/listening/media — generate listening audio ──
|
||||
@http.route("/api/exam/listening/media", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/listening/media", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def listening_media(self, **kw):
|
||||
body = _get_json()
|
||||
text = body.get("text", "")
|
||||
@@ -72,7 +77,8 @@ class MediaController(http.Controller):
|
||||
return _json_response({"error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/exam/speaking/media — generate speaking prompt audio ──
|
||||
@http.route("/api/exam/speaking/media", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/speaking/media", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def speaking_media(self, **kw):
|
||||
body = _get_json()
|
||||
text = body.get("text", "")
|
||||
@@ -89,7 +95,8 @@ class MediaController(http.Controller):
|
||||
return _json_response({"error": str(e)}, 500)
|
||||
|
||||
# ── GET /api/exam/avatars — list ELAI avatars ──
|
||||
@http.route("/api/exam/avatars", type="http", auth="public", methods=["GET"], csrf=False)
|
||||
@http.route("/api/exam/avatars", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def list_avatars(self, **kw):
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.elai_service import ElaiService
|
||||
@@ -100,7 +107,8 @@ class MediaController(http.Controller):
|
||||
return _json_response({"avatars": [], "note": str(e)})
|
||||
|
||||
# ── POST /api/exam/avatar/video — create avatar video ──
|
||||
@http.route("/api/exam/avatar/video", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/avatar/video", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def create_avatar_video(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
@@ -116,7 +124,8 @@ class MediaController(http.Controller):
|
||||
return _json_response({"error": str(e)}, 500)
|
||||
|
||||
# ── GET /api/exam/avatar/video/:id — check video status ──
|
||||
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="public", methods=["GET"], csrf=False)
|
||||
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def video_status(self, video_id, **kw):
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.elai_service import ElaiService
|
||||
@@ -126,7 +135,8 @@ class MediaController(http.Controller):
|
||||
return _json_response({"video_id": video_id, "status": "error", "error": str(e)})
|
||||
|
||||
# ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ──
|
||||
@http.route("/api/courses/ai-generate", type="http", auth="public", methods=["POST"], csrf=False)
|
||||
@http.route("/api/courses/ai-generate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def ai_generate_course(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
337
backend/custom_addons/encoach_ai/data/agents_defaults.xml
Normal file
337
backend/custom_addons/encoach_ai/data/agents_defaults.xml
Normal file
@@ -0,0 +1,337 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!--
|
||||
Default AI agents + tools seeded on first install.
|
||||
|
||||
These are the *sensible defaults* the user asked for: every platform
|
||||
pillar (course planning, weekly materials, exam generation, exercise
|
||||
generation, LMS tutor, grading) gets a pre-configured LangGraph
|
||||
agent so the system works out of the box. Admins edit the system
|
||||
prompts, models, temperatures and tool bindings from
|
||||
/admin/ai/prompts → Agents tab.
|
||||
-->
|
||||
|
||||
<!-- ============================== TOOLS ============================== -->
|
||||
|
||||
<!-- Retrieval -->
|
||||
<record id="ai_tool_resources_search" model="encoach.ai.tool">
|
||||
<field name="key">resources.search</field>
|
||||
<field name="name">Search resources</field>
|
||||
<field name="category">retrieval</field>
|
||||
<field name="description">Semantic search over the LMS resource library. Returns resource ids, titles and snippets. Use this BEFORE generating content so the agent reuses existing, approved materials instead of hallucinating.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"query":{"type":"string","description":"Natural language search query"},"skill":{"type":"string","enum":["reading","writing","listening","speaking","grammar","vocabulary"]},"cefr_level":{"type":"string","enum":["pre_a1","a1","a2","b1","b2","c1","c2"]},"limit":{"type":"integer","default":5,"minimum":1,"maximum":20}},"required":["query"]}</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_rubric_fetch" model="encoach.ai.tool">
|
||||
<field name="key">rubric.fetch</field>
|
||||
<field name="name">Fetch rubric</field>
|
||||
<field name="category">reference</field>
|
||||
<field name="description">Return the grading rubric and criterion descriptors for a given rubric id or skill. Always call before grading so the LLM uses the approved rubric, not its own defaults.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"rubric_id":{"type":"integer"},"skill":{"type":"string","enum":["reading","writing","listening","speaking"]}}}</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_outcomes_fetch" model="encoach.ai.tool">
|
||||
<field name="key">outcomes.fetch</field>
|
||||
<field name="name">Fetch course outcomes</field>
|
||||
<field name="category">reference</field>
|
||||
<field name="description">Return the registered learning outcomes for a course or CEFR level. Use it when generating course plans to stay aligned with the programme specification.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"course_id":{"type":"integer"},"cefr_level":{"type":"string","enum":["pre_a1","a1","a2","b1","b2","c1","c2"]}}}</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_student_profile" model="encoach.ai.tool">
|
||||
<field name="key">student.profile</field>
|
||||
<field name="name">Get student gap profile</field>
|
||||
<field name="category">reference</field>
|
||||
<field name="description">Return the student's CEFR band, strengths and gaps so content can be personalised. Required input for personalised exercise generation and tutor follow-ups.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"student_id":{"type":"integer"}},"required":["student_id"]}</field>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<!-- Quality gates -->
|
||||
<record id="ai_tool_quality_cefr" model="encoach.ai.tool">
|
||||
<field name="key">quality.cefr_check</field>
|
||||
<field name="name">CEFR readability check</field>
|
||||
<field name="category">quality</field>
|
||||
<field name="description">Check whether a text reads at the target CEFR level using Flesch-Kincaid. Returns ok=false with specific issues when the passage is too easy or too hard for the requested band.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"target_cefr":{"type":"string","enum":["a1","a2","b1","b2","c1","c2"]}},"required":["text","target_cefr"]}</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_quality_ai" model="encoach.ai.tool">
|
||||
<field name="key">quality.ai_detect</field>
|
||||
<field name="name">AI-content detection</field>
|
||||
<field name="category">quality</field>
|
||||
<field name="description">Probability the text was written by an AI (via GPTZero). Used during submission review — not usually during generation.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}</field>
|
||||
<field name="sequence">60</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_quality_gate" model="encoach.ai.tool">
|
||||
<field name="key">quality.content_gate</field>
|
||||
<field name="name">Unified content gate</field>
|
||||
<field name="category">quality</field>
|
||||
<field name="description">Run the project's combined content-source gate (CEFR + toxicity + length checks). Returns ok=false with the first failing rule.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"cefr_level":{"type":"string"}},"required":["text"]}</field>
|
||||
<field name="sequence">70</field>
|
||||
</record>
|
||||
|
||||
<!-- Persistence -->
|
||||
<record id="ai_tool_course_plan_save" model="encoach.ai.tool">
|
||||
<field name="key">course_plan.save</field>
|
||||
<field name="name">Save course plan</field>
|
||||
<field name="category">persistence</field>
|
||||
<field name="mutates" eval="True"/>
|
||||
<field name="description">Persist an AI-generated course plan header and its weekly rows. Only call once you're confident the JSON is valid and has been reviewed.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"plan_vals":{"type":"object"},"weeks":{"type":"array","items":{"type":"object"}}},"required":["plan_vals"]}</field>
|
||||
<field name="sequence">80</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_course_plan_save_materials" model="encoach.ai.tool">
|
||||
<field name="key">course_plan.save_materials</field>
|
||||
<field name="name">Save weekly teaching materials</field>
|
||||
<field name="category">persistence</field>
|
||||
<field name="mutates" eval="True"/>
|
||||
<field name="description">Persist the generated per-week teaching materials against an existing course plan and week.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"week_id":{"type":"integer"},"materials":{"type":"array","items":{"type":"object"}}},"required":["plan_id","week_id","materials"]}</field>
|
||||
<field name="sequence">90</field>
|
||||
</record>
|
||||
|
||||
<!-- Scoring -->
|
||||
<record id="ai_tool_scoring_writing" model="encoach.ai.tool">
|
||||
<field name="key">scoring.grade_writing</field>
|
||||
<field name="name">Grade writing response</field>
|
||||
<field name="category">scoring</field>
|
||||
<field name="description">Grade a writing response against a rubric using the platform's standard writing examiner prompt.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"rubric":{"type":"string"},"task":{"type":"string"},"response":{"type":"string"}},"required":["rubric","response"]}</field>
|
||||
<field name="sequence">100</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_scoring_speaking" model="encoach.ai.tool">
|
||||
<field name="key">scoring.grade_speaking</field>
|
||||
<field name="name">Grade speaking transcript</field>
|
||||
<field name="category">scoring</field>
|
||||
<field name="description">Grade a speaking transcript against a rubric using the platform's standard speaking examiner prompt.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"rubric":{"type":"string"},"transcript":{"type":"string"}},"required":["rubric","transcript"]}</field>
|
||||
<field name="sequence">110</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================== AGENTS ============================== -->
|
||||
|
||||
<!-- 1. Course planner -->
|
||||
<record id="ai_agent_course_planner" model="encoach.ai.agent">
|
||||
<field name="key">course_planner</field>
|
||||
<field name="name">Course Planner</field>
|
||||
<field name="description">Generates a full course plan (description, objectives, per-skill outcomes, grammar scope, assessment weights, week-by-week delivery) from a short brief. Used by the Smart Wizard and /api/ai/course-plan.</field>
|
||||
<field name="model">gpt-4o</field>
|
||||
<field name="fallback_model">gpt-4o-mini</field>
|
||||
<field name="temperature">0.4</field>
|
||||
<field name="max_tokens">4096</field>
|
||||
<field name="response_format">json</field>
|
||||
<field name="graph_type">plan_review_revise</field>
|
||||
<field name="max_revisions">1</field>
|
||||
<field name="quality_checks">quality.cefr_check</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="system_prompt">You are an expert English language curriculum designer. You produce structured, institution-grade course outlines suitable for a general foundation English programme.
|
||||
|
||||
Rules:
|
||||
- Output MUST be a single valid JSON object matching the schema the user supplies.
|
||||
- Use CEFR can-do statements when writing outcomes; cite the CEFR level in objectives.
|
||||
- Distribute the weeks so grammar and skills build cumulatively, not randomly.
|
||||
- Keep outcome codes short and stable (RLO1, WLO1, LLO1, SLO1, GLO1, VLO1) and reuse them in weeks[*].items[*].outcome_codes.
|
||||
- Never wrap the JSON in prose, markdown, or code fences.</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_outcomes_fetch'),
|
||||
ref('ai_tool_resources_search'),
|
||||
ref('ai_tool_quality_cefr'),
|
||||
ref('ai_tool_course_plan_save'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- 2. Week materials -->
|
||||
<record id="ai_agent_course_week_materials" model="encoach.ai.agent">
|
||||
<field name="key">course_week_materials</field>
|
||||
<field name="name">Week Teaching Materials</field>
|
||||
<field name="description">Given a course plan and a week number, produces classroom-ready materials (reading passage, listening script, speaking prompt, writing prompt, grammar mini-lesson, vocabulary list) aligned to the registered outcomes.</field>
|
||||
<field name="model">gpt-4o</field>
|
||||
<field name="fallback_model">gpt-4o-mini</field>
|
||||
<field name="temperature">0.6</field>
|
||||
<field name="max_tokens">6000</field>
|
||||
<field name="response_format">json</field>
|
||||
<field name="graph_type">plan_review_revise</field>
|
||||
<field name="max_revisions">1</field>
|
||||
<field name="quality_checks">quality.cefr_check</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="system_prompt">You are an expert EFL teacher creating ready-to-use classroom materials.
|
||||
|
||||
Rules:
|
||||
- Every material MUST target only the outcome codes supplied for that week.
|
||||
- Keep reading passages within the CEFR band's word-count window (A1~80, A2~150, B1~250, B2~400, C1~600, C2~800 words).
|
||||
- Listening scripts must be natural dialogue/monologue, 3-4 minutes, with 4-6 comprehension questions.
|
||||
- Speaking prompts include useful-language chunks the learner can recycle.
|
||||
- Grammar lesson: one clear rule + 3 examples + 5 practice items with answer keys.
|
||||
- Vocabulary: 8-12 entries with part of speech, CEFR-appropriate definition, and an example sentence in context.
|
||||
- Output valid JSON only; no prose or markdown around it.</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_outcomes_fetch'),
|
||||
ref('ai_tool_resources_search'),
|
||||
ref('ai_tool_quality_cefr'),
|
||||
ref('ai_tool_course_plan_save_materials'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- 3. Exam generator -->
|
||||
<record id="ai_agent_exam_generator" model="encoach.ai.agent">
|
||||
<field name="key">exam_generator</field>
|
||||
<field name="name">Exam Generator</field>
|
||||
<field name="description">Generates exam questions (MCQ, short answer, cloze, speaking prompts, writing tasks) matching a structure and blueprint.</field>
|
||||
<field name="model">gpt-4o</field>
|
||||
<field name="fallback_model">gpt-4o-mini</field>
|
||||
<field name="temperature">0.5</field>
|
||||
<field name="max_tokens">6000</field>
|
||||
<field name="response_format">json</field>
|
||||
<field name="graph_type">plan_review_revise</field>
|
||||
<field name="max_revisions">1</field>
|
||||
<field name="quality_checks">quality.cefr_check</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="system_prompt">You are a senior EFL / IELTS examiner generating authentic, validly constructed exam questions.
|
||||
|
||||
Rules:
|
||||
- Follow the exam structure blueprint exactly: same number of sections, same question types, same scoring weights.
|
||||
- Every MCQ has exactly one correct answer and three plausible distractors; avoid "all of the above".
|
||||
- Reading/listening stems reference only content present in the accompanying passage/transcript.
|
||||
- Never produce content outside the requested CEFR band.
|
||||
- Output is a single JSON object; no explanations around it.</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_resources_search'),
|
||||
ref('ai_tool_outcomes_fetch'),
|
||||
ref('ai_tool_rubric_fetch'),
|
||||
ref('ai_tool_quality_cefr'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- 4. Personalised exercise generator -->
|
||||
<record id="ai_agent_exercise_generator" model="encoach.ai.agent">
|
||||
<field name="key">exercise_generator</field>
|
||||
<field name="name">Personalised Exercise Generator</field>
|
||||
<field name="description">Produces targeted practice items (gap-fill, reordering, short response) using a learner's gap profile to focus on their weakest outcomes.</field>
|
||||
<field name="model">gpt-4o-mini</field>
|
||||
<field name="fallback_model">gpt-4o</field>
|
||||
<field name="temperature">0.7</field>
|
||||
<field name="max_tokens">3000</field>
|
||||
<field name="response_format">json</field>
|
||||
<field name="graph_type">react</field>
|
||||
<field name="max_revisions">0</field>
|
||||
<field name="quality_checks"></field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="system_prompt">You are a remedial English tutor generating short, focused practice items for one learner.
|
||||
|
||||
Workflow:
|
||||
1. Call `student.profile` with the student_id you are given. Read their CEFR band and gap_json.
|
||||
2. Optionally call `resources.search` to find an anchor text at the right level.
|
||||
3. Then produce 6-10 practice items that target the biggest gaps. Prefer item types the learner has been scoring low on.
|
||||
4. Each item has: prompt, correct_answer, distractors (for MCQ), brief rationale, target_outcome_code.
|
||||
5. Output a JSON object {"items": [...]}.
|
||||
|
||||
Never fabricate gap data — if student.profile fails, ask for a student_id in your final message and emit an empty items list.</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_student_profile'),
|
||||
ref('ai_tool_resources_search'),
|
||||
ref('ai_tool_outcomes_fetch'),
|
||||
ref('ai_tool_quality_cefr'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- 5. LMS tutor / study assistant -->
|
||||
<record id="ai_agent_lms_tutor" model="encoach.ai.agent">
|
||||
<field name="key">lms_tutor</field>
|
||||
<field name="name">LMS Tutor</field>
|
||||
<field name="description">Chat assistant students talk to from inside a lesson. Can look up their profile, search the library, fetch outcomes, and answer questions about their course.</field>
|
||||
<field name="model">gpt-4o-mini</field>
|
||||
<field name="fallback_model">gpt-4o</field>
|
||||
<field name="temperature">0.6</field>
|
||||
<field name="max_tokens">1500</field>
|
||||
<field name="response_format">text</field>
|
||||
<field name="graph_type">react</field>
|
||||
<field name="max_revisions">0</field>
|
||||
<field name="quality_checks"></field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="system_prompt">You are a friendly, encouraging English tutor inside the EnCoach LMS. You speak to learners directly.
|
||||
|
||||
Principles:
|
||||
- Adapt vocabulary and sentence length to the learner's CEFR level.
|
||||
- When the learner asks about their progress, call `student.profile` first.
|
||||
- When they ask about a topic, prefer searching `resources.search` for approved materials before inventing examples.
|
||||
- Be concrete: give one example, one practice question, one next step.
|
||||
- Never invent scores or progress data; if a tool fails, tell the learner you'll flag the issue to their teacher.</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_resources_search'),
|
||||
ref('ai_tool_student_profile'),
|
||||
ref('ai_tool_outcomes_fetch'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- 6. Writing grader -->
|
||||
<record id="ai_agent_writing_grader" model="encoach.ai.agent">
|
||||
<field name="key">writing_grader</field>
|
||||
<field name="name">Writing Grader</field>
|
||||
<field name="description">Grades a writing submission against its rubric and produces band scores, feedback, and targeted suggestions.</field>
|
||||
<field name="model">gpt-4o</field>
|
||||
<field name="fallback_model">gpt-4o-mini</field>
|
||||
<field name="temperature">0.2</field>
|
||||
<field name="max_tokens">1800</field>
|
||||
<field name="response_format">json</field>
|
||||
<field name="graph_type">simple</field>
|
||||
<field name="max_revisions">0</field>
|
||||
<field name="quality_checks"></field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="system_prompt">You are a calibrated IELTS / EFL writing examiner.
|
||||
|
||||
Rules:
|
||||
- Score every criterion in the rubric exactly once.
|
||||
- Use only band values the rubric advertises (0-9 for IELTS, 0-100 or A1-C2 for other rubrics — follow what the user sends).
|
||||
- Feedback must quote one line of evidence from the student's text before each judgement.
|
||||
- Suggestions must be specific ("replace X with Y") not generic ("improve grammar").
|
||||
- Output JSON: {"scores": {"criterion_code": number}, "overall_band": number, "feedback": string, "suggestions": [string]}.</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_rubric_fetch'),
|
||||
ref('ai_tool_scoring_writing'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- 7. Speaking evaluator -->
|
||||
<record id="ai_agent_speaking_grader" model="encoach.ai.agent">
|
||||
<field name="key">speaking_grader</field>
|
||||
<field name="name">Speaking Evaluator</field>
|
||||
<field name="description">Grades a speaking transcript against its rubric and produces band scores, feedback on fluency / pronunciation, and next-step drills.</field>
|
||||
<field name="model">gpt-4o</field>
|
||||
<field name="fallback_model">gpt-4o-mini</field>
|
||||
<field name="temperature">0.2</field>
|
||||
<field name="max_tokens">1800</field>
|
||||
<field name="response_format">json</field>
|
||||
<field name="graph_type">simple</field>
|
||||
<field name="max_revisions">0</field>
|
||||
<field name="quality_checks"></field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="system_prompt">You are a calibrated IELTS Speaking examiner judging a transcript.
|
||||
|
||||
Rules:
|
||||
- Only score what the transcript supports; pronunciation judgements must be flagged as indirect.
|
||||
- Quote a line of the transcript before each criterion judgement.
|
||||
- Suggestions prescribe one concrete drill (e.g. "practice minimal pairs /iː/ vs /ɪ/ for 2 weeks").
|
||||
- Output JSON: {"scores": {"criterion_code": number}, "overall_band": number, "feedback": string, "suggestions": [string]}.</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_rubric_fetch'),
|
||||
ref('ai_tool_scoring_speaking'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- Feature flag: pipelines consult this before routing through AgentRuntime.
|
||||
Default "True" so the defaults-ship-working contract holds. -->
|
||||
<record id="ai_default_use_langgraph" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.use_langgraph_runtime</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,3 +1,6 @@
|
||||
from . import ai_settings
|
||||
from . import ai_log
|
||||
from . import ai_prompt
|
||||
from . import ai_feedback
|
||||
from . import ai_agent
|
||||
from . import constants
|
||||
|
||||
357
backend/custom_addons/encoach_ai/models/ai_agent.py
Normal file
357
backend/custom_addons/encoach_ai/models/ai_agent.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""LangGraph-backed AI agents and the tools they can invoke.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
|
||||
The platform already has:
|
||||
|
||||
* ``encoach.ai.prompt`` — versioned prompt *templates* with rendering.
|
||||
* ``encoach.ai.log`` — per-call telemetry.
|
||||
* ``OpenAIService`` — thin wrapper around the OpenAI Python SDK.
|
||||
|
||||
This module adds the **agent layer** that sits on top of those primitives:
|
||||
|
||||
* :py:class:`EncoachAIAgent` — a named, configurable agent (e.g.
|
||||
``course_planner``, ``exam_generator``). Each agent has a system prompt,
|
||||
model choice, temperature budget, a graph topology (``simple``,
|
||||
``plan_review_revise`` or ``rag``) and an M2M list of tools it is
|
||||
allowed to call.
|
||||
|
||||
* :py:class:`EncoachAITool` — a catalogue row describing one callable
|
||||
capability. The *implementation* lives in
|
||||
``encoach_ai.services.agent_tools`` keyed by :py:attr:`key`; this model
|
||||
only stores the metadata (JSON Schema, human description, whether it
|
||||
mutates data, which audiences may use it). Admins toggle tools on or
|
||||
off per agent through the UI without touching Python code.
|
||||
|
||||
The runtime itself (LangGraph state machine, tool-routing loop, log
|
||||
emission) is in :py:mod:`encoach_ai.services.agent_runtime`. Keeping the
|
||||
models here and the execution engine there makes the runtime easy to
|
||||
swap / upgrade without touching the DB schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys follow the same shape as prompt keys: ``lowercase.dotted.identifiers``.
|
||||
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)*$")
|
||||
|
||||
GRAPH_TYPES = [
|
||||
# Single LLM call, no tools. Lowest latency, used for deterministic tasks.
|
||||
("simple", "Simple (single LLM call)"),
|
||||
# Plan → self-critique → optionally revise once. Used for long-form
|
||||
# generation (course plans, exam papers) where we want quality checks.
|
||||
("plan_review_revise", "Plan → Review → Revise"),
|
||||
# Retrieval-augmented: runs the ``search_resources`` tool first, injects
|
||||
# the hits as context, then calls the LLM. Used for curriculum-aware
|
||||
# generation that must cite existing materials.
|
||||
("rag", "Retrieval-augmented (RAG)"),
|
||||
# LLM decides which tools to call via OpenAI function-calling, in a
|
||||
# loop. Used for LMS tutor / study assistant / grading workflows that
|
||||
# may need to fetch rubrics, student profiles, etc.
|
||||
("react", "ReAct (tool-calling loop)"),
|
||||
]
|
||||
|
||||
TOOL_CATEGORIES = [
|
||||
("retrieval", "Retrieval"),
|
||||
("persistence", "Persistence"),
|
||||
("quality", "Quality & gating"),
|
||||
("scoring", "Scoring & grading"),
|
||||
("reference", "Reference lookup"),
|
||||
("other", "Other"),
|
||||
]
|
||||
|
||||
# Models we're OK advertising in the admin UI. Keep small — the whole point
|
||||
# of the agent layer is to encapsulate model choice.
|
||||
MODEL_CHOICES = [
|
||||
("gpt-4o", "GPT-4o (quality)"),
|
||||
("gpt-4o-mini", "GPT-4o mini (cheap / fast)"),
|
||||
("gpt-4.1", "GPT-4.1"),
|
||||
("gpt-4.1-mini", "GPT-4.1 mini"),
|
||||
("gpt-3.5-turbo", "GPT-3.5 turbo (legacy)"),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tool catalogue
|
||||
# =============================================================================
|
||||
class EncoachAITool(models.Model):
|
||||
_name = "encoach.ai.tool"
|
||||
_description = "AI Agent Tool"
|
||||
_order = "category, sequence, name"
|
||||
|
||||
key = fields.Char(
|
||||
required=True,
|
||||
index=True,
|
||||
help="Stable dotted identifier (e.g. 'resources.search'). The Python "
|
||||
"handler is resolved from this key via the agent_tools registry.",
|
||||
)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
description = fields.Text(
|
||||
required=True, translate=True,
|
||||
help="Shown to the LLM when the tool is exposed as a callable. "
|
||||
"Keep it concrete: explain *when* to use the tool, not *how* it works.",
|
||||
)
|
||||
category = fields.Selection(
|
||||
TOOL_CATEGORIES, required=True, default="other", index=True,
|
||||
)
|
||||
schema_json = fields.Text(
|
||||
required=True,
|
||||
default="{}",
|
||||
help="JSON Schema (Draft-07 subset) describing the tool's parameters. "
|
||||
"Passed verbatim to OpenAI function-calling.",
|
||||
)
|
||||
mutates = fields.Boolean(
|
||||
default=False,
|
||||
help="If True, the tool writes to the database. Used by the runtime "
|
||||
"to wrap calls in a savepoint so a failed LLM step can't leave half-"
|
||||
"created records behind.",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True, index=True)
|
||||
|
||||
_sql_constraints = [
|
||||
("tool_key_uniq", "unique(key)", "Tool key must be unique."),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@api.constrains("key")
|
||||
def _check_key(self):
|
||||
for rec in self:
|
||||
if not _KEY_RE.match(rec.key or ""):
|
||||
raise ValidationError(
|
||||
f"Invalid tool key {rec.key!r}. Use lowercase dotted identifiers."
|
||||
)
|
||||
|
||||
@api.constrains("schema_json")
|
||||
def _check_schema(self):
|
||||
for rec in self:
|
||||
try:
|
||||
parsed = json.loads(rec.schema_json or "{}")
|
||||
except Exception as exc:
|
||||
raise ValidationError(f"schema_json must be valid JSON: {exc}")
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValidationError("schema_json must be a JSON object.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
try:
|
||||
schema = json.loads(self.schema_json or "{}")
|
||||
except Exception:
|
||||
schema = {}
|
||||
return {
|
||||
"id": self.id,
|
||||
"key": self.key,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"category": self.category,
|
||||
"schema": schema,
|
||||
"mutates": bool(self.mutates),
|
||||
"active": bool(self.active),
|
||||
}
|
||||
|
||||
def to_openai_tool(self):
|
||||
"""Render this row in the shape OpenAI function-calling expects."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
params = json.loads(self.schema_json or "{}")
|
||||
except Exception:
|
||||
params = {}
|
||||
# Ensure the JSON Schema is OpenAI-compatible (an object with
|
||||
# ``properties``). Fall back to an empty object if the author
|
||||
# forgot to wrap parameters.
|
||||
if "type" not in params:
|
||||
params = {"type": "object", "properties": params.get("properties", {})}
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.key.replace(".", "__"),
|
||||
"description": self.description or self.name,
|
||||
"parameters": params,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent
|
||||
# =============================================================================
|
||||
class EncoachAIAgent(models.Model):
|
||||
_name = "encoach.ai.agent"
|
||||
_description = "AI Agent"
|
||||
_order = "sequence, name"
|
||||
|
||||
key = fields.Char(
|
||||
required=True,
|
||||
index=True,
|
||||
help="Stable dotted identifier the platform uses to resolve this "
|
||||
"agent (e.g. 'course_planner'). Pipelines look the agent up by key "
|
||||
"via AgentRuntime.from_key().",
|
||||
)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
description = fields.Text(translate=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True, index=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prompt & model wiring
|
||||
# ------------------------------------------------------------------
|
||||
system_prompt = fields.Text(
|
||||
required=True,
|
||||
translate=False,
|
||||
help="System message sent to the LLM. Referenced variables can be "
|
||||
"filled in by the caller via AgentRuntime.invoke(variables=...).",
|
||||
)
|
||||
prompt_key = fields.Char(
|
||||
help="Optional: key of an encoach.ai.prompt record. When set, the "
|
||||
"active version of that prompt *overrides* system_prompt at runtime. "
|
||||
"Use this when you want prompt authors to iterate without touching "
|
||||
"the agent config.",
|
||||
)
|
||||
model = fields.Selection(
|
||||
MODEL_CHOICES, required=True, default="gpt-4o",
|
||||
help="OpenAI chat model used for this agent's LLM calls.",
|
||||
)
|
||||
fallback_model = fields.Selection(
|
||||
MODEL_CHOICES, default="gpt-4o-mini",
|
||||
help="Model tried automatically if the primary model fails with a "
|
||||
"5xx / rate-limit error. Leave blank to disable the fallback.",
|
||||
)
|
||||
temperature = fields.Float(
|
||||
default=0.4,
|
||||
help="0.0 = deterministic, 1.0 = very creative. 0.3-0.5 is a sane "
|
||||
"default for structured-JSON generation.",
|
||||
)
|
||||
max_tokens = fields.Integer(default=4096)
|
||||
response_format = fields.Selection(
|
||||
[("text", "Text"), ("json", "JSON object")],
|
||||
default="json",
|
||||
help="`json` enables OpenAI's JSON mode and asks the LLM to return "
|
||||
"parseable JSON. Use `text` for free-form output (tutor chat, "
|
||||
"coach feedback).",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph & tools
|
||||
# ------------------------------------------------------------------
|
||||
graph_type = fields.Selection(
|
||||
GRAPH_TYPES, required=True, default="simple", index=True,
|
||||
help="Which LangGraph topology to use when invoking this agent.",
|
||||
)
|
||||
max_revisions = fields.Integer(
|
||||
default=1,
|
||||
help="For `plan_review_revise`: cap on how many times the agent may "
|
||||
"revise its own draft before emitting the final answer.",
|
||||
)
|
||||
quality_checks = fields.Char(
|
||||
default="",
|
||||
help="Comma-separated list of quality-gate tool keys to run after "
|
||||
"generation (e.g. 'quality.cefr_check,quality.ai_detect'). Used by "
|
||||
"the `plan_review_revise` topology.",
|
||||
)
|
||||
tool_ids = fields.Many2many(
|
||||
"encoach.ai.tool",
|
||||
"encoach_ai_agent_tool_rel",
|
||||
"agent_id", "tool_id",
|
||||
string="Tools",
|
||||
help="Tools this agent is allowed to call. For `react` graphs, "
|
||||
"these are exposed to the LLM as OpenAI function-calling tools; "
|
||||
"for `rag` / `plan_review_revise`, only tools whose key matches a "
|
||||
"pre-defined hook (e.g. `resources.search` for RAG, "
|
||||
"`quality.*` for review) are executed.",
|
||||
)
|
||||
|
||||
tool_count = fields.Integer(compute="_compute_tool_count", store=False)
|
||||
|
||||
_sql_constraints = [
|
||||
("agent_key_uniq", "unique(key)", "Agent key must be unique."),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends("tool_ids")
|
||||
def _compute_tool_count(self):
|
||||
for rec in self:
|
||||
rec.tool_count = len(rec.tool_ids)
|
||||
|
||||
@api.constrains("key")
|
||||
def _check_key(self):
|
||||
for rec in self:
|
||||
if not _KEY_RE.match(rec.key or ""):
|
||||
raise ValidationError(
|
||||
f"Invalid agent key {rec.key!r}. "
|
||||
"Use lowercase dotted identifiers (e.g. 'course_planner')."
|
||||
)
|
||||
|
||||
@api.constrains("temperature")
|
||||
def _check_temperature(self):
|
||||
for rec in self:
|
||||
if rec.temperature < 0.0 or rec.temperature > 2.0:
|
||||
raise ValidationError("Temperature must be between 0.0 and 2.0")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup helpers
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def get_by_key(self, key):
|
||||
"""Return the active agent for ``key`` or an empty recordset."""
|
||||
return self.sudo().search(
|
||||
[("key", "=", key), ("active", "=", True)], limit=1,
|
||||
)
|
||||
|
||||
def resolved_system_prompt(self, variables=None):
|
||||
"""Resolve the prompt to send: versioned prompt (if set) or inline.
|
||||
|
||||
``variables`` are passed to ``encoach.ai.prompt.render`` when the
|
||||
agent is bound to a prompt key.
|
||||
"""
|
||||
self.ensure_one()
|
||||
variables = variables or {}
|
||||
if self.prompt_key:
|
||||
prompt = self.env["encoach.ai.prompt"].sudo().get_active(self.prompt_key)
|
||||
if prompt:
|
||||
try:
|
||||
return prompt.render(variables)
|
||||
except UserError:
|
||||
# Missing variables — fall back to the inline prompt so
|
||||
# the caller still gets *something* and logs tell us
|
||||
# exactly which variable was missing.
|
||||
_logger.warning(
|
||||
"Prompt %s v%s has unfilled variables; falling back "
|
||||
"to inline system_prompt for agent %s",
|
||||
prompt.key, prompt.version, self.key,
|
||||
)
|
||||
return self.system_prompt or ""
|
||||
|
||||
def to_api_dict(self, *, include_prompt=True):
|
||||
self.ensure_one()
|
||||
data = {
|
||||
"id": self.id,
|
||||
"key": self.key,
|
||||
"name": self.name,
|
||||
"description": self.description or "",
|
||||
"model": self.model,
|
||||
"fallback_model": self.fallback_model or "",
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"response_format": self.response_format,
|
||||
"graph_type": self.graph_type,
|
||||
"max_revisions": self.max_revisions,
|
||||
"quality_checks": [
|
||||
k.strip() for k in (self.quality_checks or "").split(",") if k.strip()
|
||||
],
|
||||
"prompt_key": self.prompt_key or "",
|
||||
"tool_count": len(self.tool_ids),
|
||||
"tool_keys": self.tool_ids.mapped("key"),
|
||||
"active": bool(self.active),
|
||||
}
|
||||
if include_prompt:
|
||||
data["system_prompt"] = self.system_prompt or ""
|
||||
return data
|
||||
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(
|
||||
"op.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,11 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ai_log_admin,encoach.ai.log admin,model_encoach_ai_log,base.group_system,1,1,1,1
|
||||
access_ai_log_user,encoach.ai.log user,model_encoach_ai_log,base.group_user,1,0,1,0
|
||||
access_ai_prompt_admin,encoach.ai.prompt admin,model_encoach_ai_prompt,base.group_system,1,1,1,1
|
||||
access_ai_prompt_user,encoach.ai.prompt user,model_encoach_ai_prompt,base.group_user,1,0,0,0
|
||||
access_ai_feedback_admin,encoach.ai.feedback admin,model_encoach_ai_feedback,base.group_system,1,1,1,1
|
||||
access_ai_feedback_user,encoach.ai.feedback user,model_encoach_ai_feedback,base.group_user,1,1,1,0
|
||||
access_ai_agent_admin,encoach.ai.agent admin,model_encoach_ai_agent,base.group_system,1,1,1,1
|
||||
access_ai_agent_user,encoach.ai.agent user,model_encoach_ai_agent,base.group_user,1,0,0,0
|
||||
access_ai_tool_admin,encoach.ai.tool admin,model_encoach_ai_tool,base.group_system,1,1,1,1
|
||||
access_ai_tool_user,encoach.ai.tool user,model_encoach_ai_tool,base.group_user,1,0,0,0
|
||||
|
||||
|
@@ -5,3 +5,7 @@ from .elevenlabs_service import ElevenLabsService
|
||||
from .gptzero_service import GPTZeroService
|
||||
from .elai_service import ElaiService
|
||||
from .coach_service import CoachService
|
||||
from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9)
|
||||
from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1)
|
||||
from . import agent_tools # registry of tool handlers used by AgentRuntime
|
||||
from .agent_runtime import AgentRuntime # LangGraph-backed core agent runtime
|
||||
|
||||
500
backend/custom_addons/encoach_ai/services/agent_runtime.py
Normal file
500
backend/custom_addons/encoach_ai/services/agent_runtime.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""LangGraph-based agent runtime.
|
||||
|
||||
This is the core AI engine for EnCoach — every pipeline that used to call
|
||||
``OpenAIService`` directly can instead go through an
|
||||
:py:class:`AgentRuntime` loaded from a named :py:class:`encoach.ai.agent`
|
||||
row. That buys us:
|
||||
|
||||
* A single place to reason about retries, logging, tool execution and
|
||||
self-review, instead of the same boilerplate inside every pipeline.
|
||||
* Admins editing prompts, model choice, temperature or enabled tools in
|
||||
the UI without redeploys.
|
||||
* A consistent shape (``invoke(variables, payload)``) across course
|
||||
planning, exam generation, LMS tutor, grading, etc.
|
||||
|
||||
Graph topologies
|
||||
----------------
|
||||
|
||||
Each agent picks one of four graphs. They're all built on the same
|
||||
:py:class:`AgentState` TypedDict so upgrading an agent from one topology
|
||||
to another only means flipping a selection field.
|
||||
|
||||
``simple``
|
||||
``START → llm → END``. The workhorse — used for deterministic
|
||||
JSON generation (course plan header, exam question batches).
|
||||
|
||||
``plan_review_revise``
|
||||
``START → llm → review → [revise → llm]? → END``. ``review`` runs
|
||||
every configured quality tool (``quality.cefr_check`` etc.); if any
|
||||
returns ``ok=False`` we ask the LLM to revise once, capped by
|
||||
``max_revisions`` on the agent. Keeps structured outputs (reading
|
||||
passages, listening scripts) inside their CEFR band.
|
||||
|
||||
``rag``
|
||||
``START → retrieve → llm → END``. Runs ``resources.search`` before
|
||||
the LLM and injects the hits as extra system context. Used for
|
||||
curriculum-aware generation that must cite real library material.
|
||||
|
||||
``react``
|
||||
Classic tool-calling loop. The LLM is given the OpenAI-format tool
|
||||
list and can call tools in a loop until it emits a final answer.
|
||||
Used for the LMS tutor / study assistant.
|
||||
|
||||
We depend on ``langgraph`` for the orchestration (state machine,
|
||||
conditional edges) but still call OpenAI through the existing
|
||||
:py:class:`OpenAIService` so the API key wiring, retry behaviour and
|
||||
``encoach.ai.log`` rows keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from odoo.tools import config as odoo_config # noqa: F401 (future use)
|
||||
|
||||
from . import agent_tools
|
||||
from .openai_service import OpenAIService
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# State
|
||||
# =============================================================================
|
||||
class AgentState(TypedDict, total=False):
|
||||
"""Shared state passed between every node of the graph."""
|
||||
|
||||
messages: list[dict] # chat history sent to the LLM
|
||||
output: Any # parsed final answer (dict or string)
|
||||
output_raw: str # the raw LLM text (for logging)
|
||||
tool_calls: list[dict] # pending tool_calls emitted by the LLM
|
||||
tool_results: list[dict] # results of executed tools, appended
|
||||
quality_issues: list[str] # issues collected by the review node
|
||||
revisions_used: int # revision counter (capped by max_revisions)
|
||||
variables: dict # caller-supplied variables (for prompt rendering)
|
||||
retrieval: list[dict] # hits from the retrieval node (RAG)
|
||||
iterations: int # guard against runaway ReAct loops
|
||||
error: str # populated on fatal failure
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AgentRuntime
|
||||
# =============================================================================
|
||||
class AgentRuntime:
|
||||
"""Wraps an :py:class:`encoach.ai.agent` row with a compiled LangGraph."""
|
||||
|
||||
MAX_REACT_ITERATIONS = 6 # hard cap on tool-calling loops
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factories
|
||||
# ------------------------------------------------------------------
|
||||
def __init__(self, env, agent, *, language: str | None = None):
|
||||
self.env = env
|
||||
self.agent = agent
|
||||
self.language = language
|
||||
self.ai = OpenAIService(env, language=language)
|
||||
self._graph = None # lazily compiled
|
||||
|
||||
@classmethod
|
||||
def from_key(cls, env, key: str, *, language: str | None = None):
|
||||
"""Factory: load the active agent with ``key`` and build its runtime.
|
||||
|
||||
Returns ``None`` if no agent is configured for that key — callers
|
||||
can decide whether to fall back to their legacy direct-SDK path.
|
||||
"""
|
||||
agent = env["encoach.ai.agent"].sudo().get_by_key(key)
|
||||
if not agent:
|
||||
return None
|
||||
return cls(env, agent, language=language)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
def invoke(self, variables: dict | None = None, payload: Any = None,
|
||||
*, extra_system: str = "") -> AgentState:
|
||||
"""Run the agent's graph end-to-end and return the terminal state.
|
||||
|
||||
``variables`` are substituted into the system prompt (if the agent
|
||||
is bound to a prompt_key). ``payload`` becomes the first user
|
||||
message; pass a dict to have it rendered as JSON, or a string to
|
||||
pass it through verbatim. ``extra_system`` lets callers tack on
|
||||
a context block without touching the agent's stored prompt.
|
||||
"""
|
||||
t0 = time.time()
|
||||
graph = self._compile()
|
||||
initial = self._initial_state(variables or {}, payload, extra_system)
|
||||
try:
|
||||
# LangGraph is sync here — we're already inside an Odoo
|
||||
# request worker so sticking with sync keeps the control
|
||||
# flow simple.
|
||||
final: AgentState = graph.invoke(initial)
|
||||
except Exception as exc:
|
||||
_logger.exception("agent %s crashed", self.agent.key)
|
||||
final = {**initial, "error": str(exc)}
|
||||
|
||||
latency_ms = int((time.time() - t0) * 1000)
|
||||
self._log(final, latency_ms)
|
||||
return final
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph construction
|
||||
# ------------------------------------------------------------------
|
||||
def _compile(self):
|
||||
if self._graph is not None:
|
||||
return self._graph
|
||||
try:
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"LangGraph is not installed. "
|
||||
"Add `langgraph>=0.2.0` to backend/requirements.txt and pip install."
|
||||
) from exc
|
||||
|
||||
g = StateGraph(AgentState)
|
||||
|
||||
if self.agent.graph_type == "simple":
|
||||
g.add_node("llm", self._node_llm)
|
||||
g.add_edge(START, "llm")
|
||||
g.add_edge("llm", END)
|
||||
|
||||
elif self.agent.graph_type == "rag":
|
||||
g.add_node("retrieve", self._node_retrieve)
|
||||
g.add_node("llm", self._node_llm)
|
||||
g.add_edge(START, "retrieve")
|
||||
g.add_edge("retrieve", "llm")
|
||||
g.add_edge("llm", END)
|
||||
|
||||
elif self.agent.graph_type == "plan_review_revise":
|
||||
g.add_node("llm", self._node_llm)
|
||||
g.add_node("review", self._node_review)
|
||||
g.add_edge(START, "llm")
|
||||
g.add_edge("llm", "review")
|
||||
g.add_conditional_edges(
|
||||
"review",
|
||||
self._route_after_review,
|
||||
{"revise": "llm", "done": END},
|
||||
)
|
||||
|
||||
elif self.agent.graph_type == "react":
|
||||
g.add_node("llm", self._node_llm_tools)
|
||||
g.add_node("tools", self._node_tools)
|
||||
g.add_edge(START, "llm")
|
||||
g.add_conditional_edges(
|
||||
"llm",
|
||||
self._route_after_llm_tools,
|
||||
{"tools": "tools", "done": END},
|
||||
)
|
||||
g.add_edge("tools", "llm")
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown graph_type: {self.agent.graph_type}")
|
||||
|
||||
self._graph = g.compile()
|
||||
return self._graph
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Initial state
|
||||
# ------------------------------------------------------------------
|
||||
def _initial_state(self, variables: dict, payload: Any, extra_system: str) -> AgentState:
|
||||
system_prompt = self.agent.resolved_system_prompt(variables)
|
||||
messages: list[dict] = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
if extra_system:
|
||||
messages.append({"role": "system", "content": extra_system})
|
||||
if payload is not None:
|
||||
user_content = (
|
||||
payload if isinstance(payload, str)
|
||||
else json.dumps(payload, ensure_ascii=False)
|
||||
)
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
return {
|
||||
"messages": messages,
|
||||
"output": None,
|
||||
"output_raw": "",
|
||||
"tool_calls": [],
|
||||
"tool_results": [],
|
||||
"quality_issues": [],
|
||||
"revisions_used": 0,
|
||||
"variables": variables,
|
||||
"retrieval": [],
|
||||
"iterations": 0,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nodes
|
||||
# ------------------------------------------------------------------
|
||||
def _node_llm(self, state: AgentState) -> AgentState:
|
||||
"""Plain LLM call respecting the agent's model + response_format."""
|
||||
messages = list(state.get("messages") or [])
|
||||
model = self.agent.model
|
||||
action = f"agent.{self.agent.key}"
|
||||
try:
|
||||
if self.agent.response_format == "json":
|
||||
content = self.ai.chat_json(
|
||||
messages,
|
||||
model=model,
|
||||
temperature=self.agent.temperature,
|
||||
max_tokens=self.agent.max_tokens,
|
||||
action=action,
|
||||
)
|
||||
raw = json.dumps(content, ensure_ascii=False)
|
||||
else:
|
||||
raw = self.ai.chat(
|
||||
messages,
|
||||
model=model,
|
||||
temperature=self.agent.temperature,
|
||||
max_tokens=self.agent.max_tokens,
|
||||
action=action,
|
||||
)
|
||||
content = raw
|
||||
except Exception as exc:
|
||||
# Try the fallback model exactly once before giving up.
|
||||
if self.agent.fallback_model and model != self.agent.fallback_model:
|
||||
_logger.warning(
|
||||
"agent %s primary model %s failed (%s); retrying with %s",
|
||||
self.agent.key, model, exc, self.agent.fallback_model,
|
||||
)
|
||||
try:
|
||||
if self.agent.response_format == "json":
|
||||
content = self.ai.chat_json(
|
||||
messages, model=self.agent.fallback_model,
|
||||
temperature=self.agent.temperature,
|
||||
max_tokens=self.agent.max_tokens,
|
||||
action=f"{action}.fallback",
|
||||
)
|
||||
raw = json.dumps(content, ensure_ascii=False)
|
||||
else:
|
||||
raw = self.ai.chat(
|
||||
messages, model=self.agent.fallback_model,
|
||||
temperature=self.agent.temperature,
|
||||
max_tokens=self.agent.max_tokens,
|
||||
action=f"{action}.fallback",
|
||||
)
|
||||
content = raw
|
||||
except Exception as exc2:
|
||||
return {**state, "error": str(exc2)}
|
||||
else:
|
||||
return {**state, "error": str(exc)}
|
||||
|
||||
new_messages = messages + [{"role": "assistant", "content": raw}]
|
||||
return {
|
||||
**state,
|
||||
"messages": new_messages,
|
||||
"output": content,
|
||||
"output_raw": raw,
|
||||
}
|
||||
|
||||
def _node_retrieve(self, state: AgentState) -> AgentState:
|
||||
"""RAG node: call resources.search with the user's payload as query."""
|
||||
variables = state.get("variables") or {}
|
||||
query = ""
|
||||
# The last user message is our best default query.
|
||||
for m in reversed(state.get("messages") or []):
|
||||
if m.get("role") == "user":
|
||||
query = m.get("content") or ""
|
||||
break
|
||||
query = variables.get("query") or query
|
||||
hits = agent_tools.invoke(self.env, "resources.search", {
|
||||
"query": query[:1000],
|
||||
"limit": 5,
|
||||
})
|
||||
items = hits.get("items") or []
|
||||
context = self._format_retrieval(items)
|
||||
messages = list(state.get("messages") or [])
|
||||
if context:
|
||||
# Insert the context block *after* the system prompt(s).
|
||||
last_sys = -1
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("role") == "system":
|
||||
last_sys = i
|
||||
insert_at = last_sys + 1 if last_sys >= 0 else 0
|
||||
messages.insert(insert_at, {
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Relevant content from the library (use it when accurate, "
|
||||
"cite ids; do not fabricate):\n\n" + context
|
||||
),
|
||||
})
|
||||
return {**state, "messages": messages, "retrieval": items}
|
||||
|
||||
def _node_review(self, state: AgentState) -> AgentState:
|
||||
"""Run every configured quality tool against the LLM's output."""
|
||||
text = state.get("output_raw") or ""
|
||||
if isinstance(state.get("output"), dict):
|
||||
# Flatten the dict to text so the quality tools see something
|
||||
# meaningful (most just want prose).
|
||||
text = json.dumps(state["output"], ensure_ascii=False)
|
||||
issues: list[str] = []
|
||||
checks = [
|
||||
k.strip() for k in (self.agent.quality_checks or "").split(",")
|
||||
if k.strip()
|
||||
]
|
||||
variables = state.get("variables") or {}
|
||||
target_cefr = (
|
||||
variables.get("cefr_level")
|
||||
or variables.get("target_cefr")
|
||||
or "b1"
|
||||
)
|
||||
for key in checks:
|
||||
res = agent_tools.invoke(self.env, key, {
|
||||
"text": text,
|
||||
"target_cefr": target_cefr,
|
||||
"cefr_level": target_cefr,
|
||||
})
|
||||
if res.get("ok") is False:
|
||||
issues.extend(res.get("issues") or [res.get("error") or key])
|
||||
return {**state, "quality_issues": issues}
|
||||
|
||||
def _route_after_review(self, state: AgentState) -> str:
|
||||
issues = state.get("quality_issues") or []
|
||||
if not issues:
|
||||
return "done"
|
||||
if (state.get("revisions_used") or 0) >= max(0, self.agent.max_revisions):
|
||||
return "done"
|
||||
# Queue up a revision: add a system message with the critique and
|
||||
# bump the counter. We return via "revise" which loops back to
|
||||
# the LLM node.
|
||||
critique = (
|
||||
"Your previous draft was rejected for the following reasons:\n- "
|
||||
+ "\n- ".join(issues)
|
||||
+ "\n\nProduce an improved version that addresses every issue. "
|
||||
"Keep the same JSON schema if one was requested."
|
||||
)
|
||||
messages = list(state.get("messages") or []) + [
|
||||
{"role": "system", "content": critique}
|
||||
]
|
||||
state["messages"] = messages
|
||||
state["revisions_used"] = (state.get("revisions_used") or 0) + 1
|
||||
return "revise"
|
||||
|
||||
# ReAct / tool-calling -------------------------------------------------
|
||||
def _node_llm_tools(self, state: AgentState) -> AgentState:
|
||||
"""ReAct step: ask the LLM, exposing all enabled tools."""
|
||||
if state.get("iterations", 0) >= self.MAX_REACT_ITERATIONS:
|
||||
return {**state, "error": "react_iteration_limit_exceeded"}
|
||||
|
||||
client = self.ai.client
|
||||
if client is None:
|
||||
return {**state, "error": "openai_not_configured"}
|
||||
|
||||
tools = [t.to_openai_tool() for t in self.agent.tool_ids]
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=self.agent.model,
|
||||
messages=state.get("messages") or [],
|
||||
temperature=self.agent.temperature,
|
||||
max_tokens=self.agent.max_tokens,
|
||||
tools=tools or None,
|
||||
tool_choice="auto" if tools else None,
|
||||
timeout=self.ai.request_timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {**state, "error": str(exc)}
|
||||
|
||||
choice = resp.choices[0].message
|
||||
assistant_msg: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": choice.content or "",
|
||||
}
|
||||
tool_calls = []
|
||||
if getattr(choice, "tool_calls", None):
|
||||
# Preserve the OpenAI-shaped tool_calls list on the message so
|
||||
# the next round references them by id.
|
||||
assistant_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in choice.tool_calls
|
||||
]
|
||||
for tc in choice.tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments or "{}")
|
||||
except Exception:
|
||||
args = {}
|
||||
tool_calls.append({
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"args": args,
|
||||
})
|
||||
|
||||
new_messages = list(state.get("messages") or []) + [assistant_msg]
|
||||
return {
|
||||
**state,
|
||||
"messages": new_messages,
|
||||
"tool_calls": tool_calls,
|
||||
"output": choice.content or state.get("output"),
|
||||
"output_raw": choice.content or state.get("output_raw") or "",
|
||||
"iterations": state.get("iterations", 0) + 1,
|
||||
}
|
||||
|
||||
def _route_after_llm_tools(self, state: AgentState) -> str:
|
||||
if state.get("error"):
|
||||
return "done"
|
||||
if state.get("tool_calls"):
|
||||
return "tools"
|
||||
return "done"
|
||||
|
||||
def _node_tools(self, state: AgentState) -> AgentState:
|
||||
"""Execute every queued tool_call and append results to the chat."""
|
||||
allowed = {t.key: t for t in self.agent.tool_ids}
|
||||
messages = list(state.get("messages") or [])
|
||||
results: list[dict] = list(state.get("tool_results") or [])
|
||||
for call in state.get("tool_calls") or []:
|
||||
# Tools are stored with dotted keys but OpenAI flattens dots to
|
||||
# double-underscores (because function names must match [A-Za-z0-9_]).
|
||||
key = (call.get("name") or "").replace("__", ".")
|
||||
if key not in allowed:
|
||||
result = {"error": f"tool_not_allowed:{key}"}
|
||||
else:
|
||||
result = agent_tools.invoke(self.env, key, call.get("args") or {})
|
||||
results.append({"tool": key, "args": call.get("args"), "result": result})
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.get("id"),
|
||||
"name": call.get("name") or key,
|
||||
"content": json.dumps(result, ensure_ascii=False, default=str)[:6000],
|
||||
})
|
||||
return {
|
||||
**state,
|
||||
"messages": messages,
|
||||
"tool_calls": [],
|
||||
"tool_results": results,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _format_retrieval(items: list[dict]) -> str:
|
||||
parts = []
|
||||
for r in items or []:
|
||||
label = f"[{r.get('type','?')}#{r.get('id','?')}]"
|
||||
title = r.get("title") or ""
|
||||
snippet = (r.get("snippet") or "")[:400]
|
||||
parts.append(f"{label} {title}\n{snippet}")
|
||||
return "\n---\n".join(parts)
|
||||
|
||||
def _log(self, final: AgentState, latency_ms: int):
|
||||
try:
|
||||
self.env["encoach.ai.log"].sudo().create({
|
||||
"service": "openai",
|
||||
"action": f"agent.{self.agent.key}",
|
||||
"model_used": self.agent.model,
|
||||
"latency_ms": latency_ms,
|
||||
"status": "error" if final.get("error") else "success",
|
||||
"error_message": final.get("error") or "",
|
||||
"input_preview": json.dumps(final.get("variables") or {})[:500],
|
||||
"output_preview": (final.get("output_raw") or "")[:500],
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning("agent %s log write failed", self.agent.key, exc_info=True)
|
||||
326
backend/custom_addons/encoach_ai/services/agent_tools.py
Normal file
326
backend/custom_addons/encoach_ai/services/agent_tools.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Python implementations of the tools the agent runtime can invoke.
|
||||
|
||||
Every :py:class:`encoach.ai.tool` row in the DB points to one of the
|
||||
handler functions registered here via :py:func:`register`. The DB row
|
||||
holds the metadata (description, JSON Schema, admin toggle); the real
|
||||
logic is Python so it can import Odoo models, run transactions and
|
||||
reuse existing services (vector search, quality gates, etc.).
|
||||
|
||||
Tools follow a strict contract:
|
||||
|
||||
* Signature: ``handler(env, **params) -> dict``.
|
||||
* The returned dict must be JSON-serialisable. It becomes the tool
|
||||
message the LLM sees next, so keys should be descriptive.
|
||||
* Tools that write to the DB must set ``mutates=True`` on their catalogue
|
||||
row so the runtime wraps the call in a savepoint.
|
||||
* Tools never raise: catch exceptions and return ``{"error": str(exc)}``
|
||||
so the agent can reason about failures and the top-level call keeps
|
||||
going instead of aborting the whole graph.
|
||||
|
||||
Adding a new tool
|
||||
-----------------
|
||||
|
||||
1. Write a handler below, decorated with ``@register("namespace.name")``.
|
||||
2. Add a seed row in ``data/agents_defaults.xml`` with the same key.
|
||||
3. Bind it to one or more agents in the same seed file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Registry: tool key → handler callable.
|
||||
_REGISTRY: dict[str, Callable[..., dict]] = {}
|
||||
|
||||
|
||||
def register(key: str):
|
||||
"""Decorator to register a tool handler under ``key``."""
|
||||
|
||||
def decorator(func: Callable[..., dict]) -> Callable[..., dict]:
|
||||
if key in _REGISTRY:
|
||||
_logger.warning("Agent tool %r is being re-registered", key)
|
||||
_REGISTRY[key] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_handler(key: str) -> Callable[..., dict] | None:
|
||||
return _REGISTRY.get(key)
|
||||
|
||||
|
||||
def list_keys() -> list[str]:
|
||||
return sorted(_REGISTRY.keys())
|
||||
|
||||
|
||||
def invoke(env, key: str, params: dict | None = None) -> dict:
|
||||
"""Resolve and invoke the handler for ``key`` with ``params``.
|
||||
|
||||
All tool failures are normalised to ``{"error": "..."}`` so callers
|
||||
(LangGraph nodes) don't need to care about exceptions. A missing
|
||||
handler returns ``{"error": "unknown_tool"}``.
|
||||
"""
|
||||
handler = _REGISTRY.get(key)
|
||||
if not handler:
|
||||
return {"error": f"unknown_tool:{key}"}
|
||||
try:
|
||||
return handler(env, **(params or {}))
|
||||
except TypeError as exc:
|
||||
# Bad arguments from the LLM — make the complaint readable.
|
||||
return {"error": f"bad_arguments: {exc}"}
|
||||
except Exception as exc:
|
||||
_logger.exception("agent tool %s failed", key)
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Built-in tools
|
||||
# =============================================================================
|
||||
#
|
||||
# Each handler is deliberately small: they're thin adapters over services
|
||||
# we already have. The LLM gets a compact JSON payload to reason over.
|
||||
|
||||
|
||||
# --- Retrieval ----------------------------------------------------------------
|
||||
@register("resources.search")
|
||||
def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "",
|
||||
limit: int = 5, **_: Any) -> dict:
|
||||
"""Semantic search over the LMS resource library.
|
||||
|
||||
Returns titles + short snippets so the agent can cite existing
|
||||
materials instead of inventing new ones every run.
|
||||
"""
|
||||
from odoo.addons.encoach_vector.services.embedding_service import (
|
||||
EmbeddingService, # noqa: F401
|
||||
)
|
||||
try:
|
||||
svc = EmbeddingService(env)
|
||||
# EmbeddingService.search is expected to filter by content_type;
|
||||
# we accept a skill filter from the agent but don't require it.
|
||||
results = svc.search(query or "", limit=int(limit or 5))
|
||||
except Exception as exc:
|
||||
_logger.debug("resource vector search unavailable: %s", exc)
|
||||
results = []
|
||||
out = []
|
||||
for r in results or []:
|
||||
out.append({
|
||||
"id": r.get("content_id") or r.get("id"),
|
||||
"type": r.get("content_type") or "",
|
||||
"title": (r.get("metadata") or {}).get("title", ""),
|
||||
"snippet": (r.get("text") or "")[:400],
|
||||
"similarity": r.get("similarity"),
|
||||
})
|
||||
return {"query": query, "count": len(out), "items": out}
|
||||
|
||||
|
||||
@register("rubric.fetch")
|
||||
def _fetch_rubric(env, rubric_id: int | None = None, skill: str = "", **_: Any) -> dict:
|
||||
"""Return rubric criteria for a given id, or the newest rubric for a skill."""
|
||||
Rubric = env["encoach.rubric"].sudo() if "encoach.rubric" in env else None
|
||||
if Rubric is None:
|
||||
return {"error": "rubric_model_missing"}
|
||||
rec = None
|
||||
if rubric_id:
|
||||
rec = Rubric.browse(int(rubric_id))
|
||||
if not rec.exists():
|
||||
rec = None
|
||||
if rec is None and skill:
|
||||
rec = Rubric.search(
|
||||
[("skill", "=", skill)], order="create_date desc", limit=1,
|
||||
)
|
||||
if not rec:
|
||||
return {"error": "rubric_not_found"}
|
||||
criteria = []
|
||||
for crit in getattr(rec, "criterion_ids", rec.browse([])):
|
||||
criteria.append({
|
||||
"code": getattr(crit, "code", "") or "",
|
||||
"name": crit.name or "",
|
||||
"weight": getattr(crit, "weight", 0) or 0,
|
||||
"descriptors": getattr(crit, "descriptors", "") or "",
|
||||
})
|
||||
return {
|
||||
"id": rec.id,
|
||||
"name": rec.name,
|
||||
"skill": getattr(rec, "skill", "") or "",
|
||||
"criteria": criteria,
|
||||
}
|
||||
|
||||
|
||||
@register("outcomes.fetch")
|
||||
def _fetch_course_outcomes(env, course_id: int | None = None,
|
||||
cefr_level: str = "", **_: Any) -> dict:
|
||||
"""Return learning outcomes for a course (or CEFR level)."""
|
||||
LO = env["encoach.learning.objective"].sudo() \
|
||||
if "encoach.learning.objective" in env else None
|
||||
if LO is None:
|
||||
return {"error": "learning_objective_model_missing"}
|
||||
domain = []
|
||||
if course_id:
|
||||
domain.append(("course_id", "=", int(course_id)))
|
||||
if cefr_level:
|
||||
domain.append(("cefr_level", "=", cefr_level))
|
||||
records = LO.search(domain, limit=200)
|
||||
return {
|
||||
"count": len(records),
|
||||
"items": [{
|
||||
"id": r.id,
|
||||
"code": getattr(r, "code", "") or "",
|
||||
"skill": getattr(r, "skill", "") or "",
|
||||
"cefr_level": getattr(r, "cefr_level", "") or "",
|
||||
"description": r.name or getattr(r, "description", "") or "",
|
||||
} for r in records],
|
||||
}
|
||||
|
||||
|
||||
@register("student.profile")
|
||||
def _fetch_student_profile(env, student_id: int, **_: Any) -> dict:
|
||||
"""Return a compact gap-profile the agent can use to personalise content."""
|
||||
SP = env["encoach.student.profile"].sudo() \
|
||||
if "encoach.student.profile" in env else None
|
||||
if SP is None:
|
||||
return {"error": "student_profile_model_missing"}
|
||||
rec = SP.search([("student_id", "=", int(student_id))], limit=1)
|
||||
if not rec:
|
||||
return {"error": "profile_not_found"}
|
||||
return {
|
||||
"student_id": int(student_id),
|
||||
"cefr_level": getattr(rec, "cefr_level", "") or "",
|
||||
"strengths_json": getattr(rec, "strengths_json", "") or "",
|
||||
"gaps_json": getattr(rec, "gaps_json", "") or "",
|
||||
}
|
||||
|
||||
|
||||
# --- Quality gates ------------------------------------------------------------
|
||||
@register("quality.cefr_check")
|
||||
def _cefr_check(env, text: str = "", target_cefr: str = "b1", **_: Any) -> dict:
|
||||
"""Grade the readability of ``text`` against a target CEFR band."""
|
||||
try:
|
||||
import textstat # noqa: F401
|
||||
fk = textstat.flesch_kincaid_grade(text or "")
|
||||
fre = textstat.flesch_reading_ease(text or "")
|
||||
except Exception:
|
||||
fk, fre = None, None
|
||||
# Rough mapping — deliberately conservative; the LLM uses it as a hint.
|
||||
band_map = {"a1": (1, 3), "a2": (3, 5), "b1": (5, 7),
|
||||
"b2": (7, 9), "c1": (9, 12), "c2": (12, 20)}
|
||||
ok = True
|
||||
issues = []
|
||||
if fk is not None:
|
||||
lo, hi = band_map.get((target_cefr or "b1").lower(), (5, 7))
|
||||
if fk < lo - 0.5:
|
||||
ok = False
|
||||
issues.append(f"Text reads below {target_cefr.upper()} (FK={fk:.1f})")
|
||||
elif fk > hi + 0.5:
|
||||
ok = False
|
||||
issues.append(f"Text reads above {target_cefr.upper()} (FK={fk:.1f})")
|
||||
return {
|
||||
"ok": ok,
|
||||
"target_cefr": target_cefr,
|
||||
"flesch_kincaid": fk,
|
||||
"flesch_reading_ease": fre,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
@register("quality.ai_detect")
|
||||
def _ai_detect(env, text: str = "", **_: Any) -> dict:
|
||||
"""Return AI-detection probability if GPTZero is configured, else neutral."""
|
||||
try:
|
||||
# Try to reuse whatever GPTZero wrapper the platform already has.
|
||||
from odoo.addons.encoach_ai.services.gptzero_service import (
|
||||
GPTZeroService, # type: ignore
|
||||
)
|
||||
svc = GPTZeroService(env)
|
||||
return svc.score(text)
|
||||
except Exception as exc:
|
||||
_logger.debug("gptzero unavailable: %s", exc)
|
||||
return {"ok": True, "ai_probability": None, "note": "detector_unavailable"}
|
||||
|
||||
|
||||
@register("quality.content_gate")
|
||||
def _content_gate(env, text: str = "", cefr_level: str = "b1", **_: Any) -> dict:
|
||||
"""Run the project's unified content-source gate (if installed)."""
|
||||
try:
|
||||
from odoo.addons.encoach_quality_gate.services.content_source_gate import (
|
||||
ContentSourceGate, # type: ignore
|
||||
)
|
||||
gate = ContentSourceGate(env)
|
||||
return gate.check(text, cefr_level=cefr_level)
|
||||
except Exception as exc:
|
||||
_logger.debug("content_gate unavailable: %s", exc)
|
||||
return {"ok": True, "note": "gate_unavailable"}
|
||||
|
||||
|
||||
# --- Persistence --------------------------------------------------------------
|
||||
@register("course_plan.save")
|
||||
def _save_course_plan(env, plan_vals: dict, weeks: list | None = None, **_: Any) -> dict:
|
||||
"""Persist an AI-generated course plan. Used by the course_planner agent.
|
||||
|
||||
``plan_vals`` is the dict the LLM produced (after the runtime's JSON
|
||||
normalisation). ``weeks`` is the list of per-week rows. The handler is
|
||||
idempotent-friendly: it always creates new rows (agents are expected
|
||||
to decide whether to reuse an existing plan before calling this).
|
||||
"""
|
||||
Plan = env["encoach.course.plan"].sudo() if "encoach.course.plan" in env else None
|
||||
Week = env["encoach.course.plan.week"].sudo() \
|
||||
if "encoach.course.plan.week" in env else None
|
||||
if Plan is None or Week is None:
|
||||
return {"error": "course_plan_models_missing"}
|
||||
plan = Plan.create(plan_vals or {})
|
||||
created_weeks = 0
|
||||
for w in weeks or []:
|
||||
try:
|
||||
Week.create({**w, "plan_id": plan.id})
|
||||
created_weeks += 1
|
||||
except Exception as exc:
|
||||
_logger.warning("agent tool course_plan.save: bad week row %r: %s", w, exc)
|
||||
return {"plan_id": plan.id, "weeks_created": created_weeks}
|
||||
|
||||
|
||||
@register("course_plan.save_materials")
|
||||
def _save_week_materials(env, plan_id: int, week_id: int,
|
||||
materials: list | None = None, **_: Any) -> dict:
|
||||
Material = env["encoach.course.plan.material"].sudo() \
|
||||
if "encoach.course.plan.material" in env else None
|
||||
if Material is None:
|
||||
return {"error": "material_model_missing"}
|
||||
created = 0
|
||||
for m in materials or []:
|
||||
try:
|
||||
Material.create({
|
||||
**m,
|
||||
"plan_id": int(plan_id),
|
||||
"week_id": int(week_id),
|
||||
"body_json": json.dumps(m.get("body") or {}, ensure_ascii=False)
|
||||
if "body" in m else m.get("body_json", ""),
|
||||
})
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
_logger.warning("agent tool save_materials: bad row %r: %s", m, exc)
|
||||
return {"plan_id": plan_id, "week_id": week_id, "materials_created": created}
|
||||
|
||||
|
||||
# --- Scoring (best-effort wrappers over existing services) --------------------
|
||||
@register("scoring.grade_writing")
|
||||
def _grade_writing(env, rubric: str = "", task: str = "", response: str = "",
|
||||
**_: Any) -> dict:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
svc = OpenAIService(env)
|
||||
try:
|
||||
return svc.grade_writing(rubric, task, response)
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@register("scoring.grade_speaking")
|
||||
def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> dict:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
svc = OpenAIService(env)
|
||||
try:
|
||||
return svc.grade_speaking(rubric, transcript)
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
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)
|
||||
@@ -9,10 +9,10 @@ _logger = logging.getLogger(__name__)
|
||||
class CoachService:
|
||||
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
|
||||
|
||||
def __init__(self, env):
|
||||
def __init__(self, env, *, language=None):
|
||||
from .openai_service import OpenAIService
|
||||
self.env = env
|
||||
self.ai = OpenAIService(env)
|
||||
self.ai = OpenAIService(env, language=language)
|
||||
|
||||
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
|
||||
try:
|
||||
|
||||
@@ -12,24 +12,106 @@ except ImportError:
|
||||
_openai_mod = None
|
||||
|
||||
|
||||
# Human-readable names for the UI languages we support. Kept in sync with the
|
||||
# frontend i18n language set. When a user has the UI in Arabic (`ar`), we want
|
||||
# the LLM to reply in Arabic too — otherwise the user sees Arabic chrome with
|
||||
# English AI content, which is what they reported as "not translated correct".
|
||||
_LANGUAGE_NAMES = {
|
||||
"en": "English",
|
||||
"ar": "Arabic",
|
||||
"fr": "French",
|
||||
"es": "Spanish",
|
||||
"de": "German",
|
||||
"ru": "Russian",
|
||||
"tr": "Turkish",
|
||||
"fa": "Persian",
|
||||
"ur": "Urdu",
|
||||
"hi": "Hindi",
|
||||
"zh": "Chinese",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language(code):
|
||||
"""Pull a short ISO-639-1 code out of a raw Accept-Language-style string.
|
||||
|
||||
Handles ``ar``, ``ar-EG``, ``ar-EG,en;q=0.9`` and friends. Falls back to
|
||||
``en`` for anything we don't recognise so the AI always has a concrete
|
||||
target language and never reverts to an empty prompt.
|
||||
"""
|
||||
if not code:
|
||||
return "en"
|
||||
token = str(code).strip().split(",")[0].split(";")[0].strip().lower()
|
||||
short = token.split("-")[0]
|
||||
return short if short in _LANGUAGE_NAMES else "en"
|
||||
|
||||
|
||||
class OpenAIService:
|
||||
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
|
||||
|
||||
def __init__(self, env):
|
||||
def __init__(self, env, *, language=None):
|
||||
self.env = env
|
||||
self._get_param = env["ir.config_parameter"].sudo().get_param
|
||||
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
|
||||
self.max_retries = int(self._get_param("encoach_ai.max_retries", "3"))
|
||||
# Hard wall-clock timeout (seconds) enforced by the OpenAI SDK on each
|
||||
# HTTP call. Prevents a single slow LLM request from tying up an Odoo
|
||||
# worker for minutes. See P0.5 in docs/PROJECT_SUMMARY.md §21.
|
||||
self.request_timeout = float(self._get_param("encoach_ai.request_timeout", "30"))
|
||||
api_key = self._get_param("encoach_ai.openai_api_key", "")
|
||||
if not api_key:
|
||||
import os
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if _openai_mod and api_key:
|
||||
self.client = _openai_mod.OpenAI(api_key=api_key)
|
||||
self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout)
|
||||
else:
|
||||
self.client = None
|
||||
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
|
||||
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo")
|
||||
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini")
|
||||
self.language = _normalize_language(language)
|
||||
|
||||
def _language_system_message(self):
|
||||
"""Return a system message that forces the LLM to answer in the user's
|
||||
UI language, or ``None`` for English (the model's default).
|
||||
|
||||
We keep the original English prompts (which are tuned for JSON
|
||||
structure) and simply tack a language instruction on the end. This
|
||||
preserves behaviour for ``en`` users while giving Arabic users Arabic
|
||||
output without having to translate every prompt in the codebase.
|
||||
"""
|
||||
if not self.language or self.language == "en":
|
||||
return None
|
||||
lang_name = _LANGUAGE_NAMES.get(self.language, "English")
|
||||
return {
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"LOCALIZATION: Write every user-facing natural-language string "
|
||||
f"(titles, descriptions, explanations, feedback, recommendations, "
|
||||
f"suggestions, motivation, narrative) in {lang_name}. "
|
||||
"Keep JSON keys, enum values (e.g. 'info', 'warning', 'critical', "
|
||||
"'TRUE', 'FALSE', 'NOT GIVEN'), CEFR band codes (A1-C2), band numbers, "
|
||||
"and identifiers in their original form. Do not translate rubric "
|
||||
"category names that appear as JSON keys."
|
||||
),
|
||||
}
|
||||
|
||||
def _inject_language(self, messages):
|
||||
"""Prepend the localization instruction after the existing system
|
||||
prompt(s) so it doesn't displace the structural prompt but is still
|
||||
heeded by the model."""
|
||||
lang_msg = self._language_system_message()
|
||||
if not lang_msg:
|
||||
return messages
|
||||
messages = list(messages)
|
||||
# Find the index of the last system message so we append after it.
|
||||
last_system = -1
|
||||
for i, m in enumerate(messages):
|
||||
if isinstance(m, dict) and m.get("role") == "system":
|
||||
last_system = i
|
||||
insert_at = last_system + 1 if last_system >= 0 else 0
|
||||
messages.insert(insert_at, lang_msg)
|
||||
return messages
|
||||
|
||||
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
|
||||
try:
|
||||
@@ -78,6 +160,7 @@ class OpenAIService:
|
||||
if not self.client:
|
||||
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||
model = model or self.model
|
||||
messages = self._inject_language(messages)
|
||||
t0 = time.time()
|
||||
try:
|
||||
def _call():
|
||||
@@ -86,6 +169,7 @@ class OpenAIService:
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
resp = self._retry_with_backoff(_call, action, model)
|
||||
content = resp.choices[0].message.content
|
||||
@@ -103,6 +187,7 @@ class OpenAIService:
|
||||
if not self.client:
|
||||
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||
model = model or self.model
|
||||
messages = self._inject_language(messages)
|
||||
t0 = time.time()
|
||||
try:
|
||||
def _call():
|
||||
@@ -112,6 +197,7 @@ class OpenAIService:
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
response_format={"type": "json_object"},
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
resp = self._retry_with_backoff(_call, action, model)
|
||||
raw = resp.choices[0].message.content
|
||||
|
||||
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,2 @@
|
||||
from . import ai_course
|
||||
from . import course_plan
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""REST endpoints for AI course-plan generation and browsing.
|
||||
|
||||
All endpoints sit under ``/api/ai/course-plan`` so they don't collide
|
||||
with the existing ``/api/ai-course/...`` English / IELTS generation
|
||||
endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
|
||||
decorator and returns 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,
|
||||
_paginate,
|
||||
)
|
||||
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
|
||||
CoursePlanPipeline,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _request_language():
|
||||
"""Return the UI language sent by the frontend as a short ISO code."""
|
||||
try:
|
||||
raw = (
|
||||
request.httprequest.headers.get('X-UI-Language')
|
||||
or request.httprequest.headers.get('Accept-Language')
|
||||
or 'en'
|
||||
)
|
||||
except Exception:
|
||||
raw = 'en'
|
||||
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
|
||||
|
||||
|
||||
class CoursePlanController(http.Controller):
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai/course-plan', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def generate_plan(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
if not (body.get('title') or '').strip():
|
||||
return _error_response('title is required', 400)
|
||||
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
plan = pipeline.generate_plan(body)
|
||||
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai/course-plan', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_plans(self, **kw):
|
||||
try:
|
||||
params = request.httprequest.args
|
||||
domain = []
|
||||
search = (params.get('search') or '').strip()
|
||||
if search:
|
||||
domain.append(('name', 'ilike', search))
|
||||
|
||||
Plan = request.env['encoach.course.plan'].sudo()
|
||||
offset, limit, page = _paginate({
|
||||
'page': params.get('page', 0),
|
||||
'size': params.get('size', 20),
|
||||
})
|
||||
total = Plan.search_count(domain)
|
||||
records = Plan.search(
|
||||
domain, offset=offset, limit=limit,
|
||||
order='create_date desc, id desc',
|
||||
)
|
||||
return _json_response({
|
||||
'items': [r.to_api_dict(include_weeks=False) for r in records],
|
||||
'page': {'page': page, 'size': limit, 'total': total},
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
|
||||
auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
return _json_response({
|
||||
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.get failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/ai/course-plan/<id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
|
||||
auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def generate_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
materials = pipeline.generate_week_materials(plan_id, week_number)
|
||||
return _json_response({
|
||||
'items': [m.to_api_dict() for m in materials],
|
||||
'count': len(materials),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
week = request.env['encoach.course.plan.week'].sudo().search([
|
||||
('plan_id', '=', int(plan_id)),
|
||||
('week_number', '=', int(week_number)),
|
||||
], limit=1)
|
||||
if not week:
|
||||
return _error_response('Week not found', 404)
|
||||
return _json_response({
|
||||
'items': [m.to_api_dict() for m in week.material_ids],
|
||||
'count': len(week.material_ids),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import ai_generation_log
|
||||
from . import ai_ielts_generation_log
|
||||
from . import course_plan
|
||||
|
||||
276
backend/custom_addons/encoach_ai_course/models/course_plan.py
Normal file
276
backend/custom_addons/encoach_ai_course/models/course_plan.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Course Plan models.
|
||||
|
||||
A *course plan* is the AI-generated, structured outline of a full course
|
||||
(similar to the UTAS GE1 outline: objectives, per-skill learning outcomes,
|
||||
grammar scope, assessment split, and a week-by-week delivery plan).
|
||||
|
||||
Distinct from the existing exam / exercise generation pipeline:
|
||||
|
||||
* ``encoach.ai.generation.log`` generates **exam questions**.
|
||||
* ``encoach.course.plan`` generates **teaching content** — weeks,
|
||||
reading texts, listening scripts, speaking prompts, grammar lessons, etc.
|
||||
|
||||
Large, loosely-structured JSON (objectives, learning outcomes grouped by
|
||||
skill, grammar topics, assessment breakdown, learning resources) lives on
|
||||
the header as ``Text`` columns to keep the schema boring. Per-week rows
|
||||
and per-week materials each get their own table because they are
|
||||
generated incrementally and users want to drill into them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SKILL_SELECTION = [
|
||||
('reading', 'Reading'),
|
||||
('writing', 'Writing'),
|
||||
('listening', 'Listening'),
|
||||
('speaking', 'Speaking'),
|
||||
('grammar', 'Grammar'),
|
||||
('vocabulary', 'Vocabulary'),
|
||||
('integrated', 'Integrated'),
|
||||
]
|
||||
|
||||
|
||||
MATERIAL_TYPE_SELECTION = [
|
||||
('reading_text', 'Reading Text'),
|
||||
('listening_script', 'Listening Script'),
|
||||
('speaking_prompt', 'Speaking Prompt'),
|
||||
('writing_prompt', 'Writing Prompt'),
|
||||
('grammar_lesson', 'Grammar Lesson'),
|
||||
('vocabulary_list', 'Vocabulary List'),
|
||||
('practice', 'Practice Exercises'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
|
||||
|
||||
class CoursePlan(models.Model):
|
||||
_name = 'encoach.course.plan'
|
||||
_description = 'AI-generated Course Plan'
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
('a1', 'A1'),
|
||||
('a2', 'A2'),
|
||||
('b1', 'B1'),
|
||||
('b2', 'B2'),
|
||||
('c1', 'C1'),
|
||||
('c2', 'C2'),
|
||||
], default='a2')
|
||||
|
||||
total_weeks = fields.Integer(default=12, string='Total weeks')
|
||||
contact_hours_per_week = fields.Integer(default=18, string='Contact hours / week')
|
||||
|
||||
# The "Reading & Writing = 10 hrs/wk, Listening & Speaking = 8 hrs/wk"
|
||||
# breakdown is a free-form label so AI can propose any split.
|
||||
skills_division = fields.Char(
|
||||
string='Skills division',
|
||||
help='Free-form label describing how hours are split across skill '
|
||||
'tracks, e.g. "10 hrs/wk Reading & Writing + 8 hrs/wk '
|
||||
'Listening & Speaking".',
|
||||
)
|
||||
|
||||
description = fields.Text()
|
||||
objectives_json = fields.Text(
|
||||
help='JSON array of high-level course objectives.',
|
||||
)
|
||||
outcomes_json = fields.Text(
|
||||
help='JSON object keyed by skill (reading/writing/listening/speaking/'
|
||||
'vocabulary/grammar). Each value is an ordered list of '
|
||||
'{code, description} learning outcome rows — code is e.g. '
|
||||
'"RLO1", "WLO3", "GLO2a".',
|
||||
)
|
||||
grammar_json = fields.Text(
|
||||
help='JSON array of grammar topics in the order they should be '
|
||||
'taught. Each item is {code, label, sub_items: []}.',
|
||||
)
|
||||
assessment_json = fields.Text(
|
||||
help='JSON object describing the CA/FE split and component weights.',
|
||||
)
|
||||
resources_json = fields.Text(
|
||||
help='JSON array of textbooks / URLs / materials referenced by the '
|
||||
'AI when planning content.',
|
||||
)
|
||||
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('generated', 'Generated'),
|
||||
('approved', 'Approved'),
|
||||
('archived', 'Archived'),
|
||||
], default='draft')
|
||||
|
||||
brief_json = fields.Text(
|
||||
help='Original brief that was sent to the AI — kept for audit and '
|
||||
'so the user can re-generate if the first pass disappoints.',
|
||||
)
|
||||
|
||||
week_ids = fields.One2many(
|
||||
'encoach.course.plan.week', 'plan_id', string='Weeks',
|
||||
)
|
||||
material_ids = fields.One2many(
|
||||
'encoach.course.plan.material', 'plan_id', string='Materials',
|
||||
)
|
||||
|
||||
week_count = fields.Integer(compute='_compute_counts', store=False)
|
||||
material_count = fields.Integer(compute='_compute_counts', store=False)
|
||||
|
||||
@api.depends('week_ids', 'material_ids')
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.week_count = len(rec.week_ids)
|
||||
rec.material_count = len(rec.material_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation helpers — used by the REST controller so payload
|
||||
# shape stays in a single, obvious place.
|
||||
# ------------------------------------------------------------------
|
||||
def _loads(self, raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def to_api_dict(self, *, include_weeks=True, include_materials=False):
|
||||
self.ensure_one()
|
||||
data = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'course_id': self.course_id.id if self.course_id else None,
|
||||
'course_name': self.course_id.name if self.course_id else '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
'total_weeks': self.total_weeks or 0,
|
||||
'contact_hours_per_week': self.contact_hours_per_week or 0,
|
||||
'skills_division': self.skills_division or '',
|
||||
'description': self.description or '',
|
||||
'status': self.status or 'draft',
|
||||
'objectives': self._loads(self.objectives_json, []),
|
||||
'outcomes': self._loads(self.outcomes_json, {}),
|
||||
'grammar': self._loads(self.grammar_json, []),
|
||||
'assessment': self._loads(self.assessment_json, {}),
|
||||
'resources': self._loads(self.resources_json, []),
|
||||
'week_count': len(self.week_ids),
|
||||
'material_count': len(self.material_ids),
|
||||
'created_at': self.create_date.isoformat() if self.create_date else None,
|
||||
}
|
||||
if include_weeks:
|
||||
data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')]
|
||||
if include_materials:
|
||||
data['materials'] = [m.to_api_dict() for m in self.material_ids]
|
||||
return data
|
||||
|
||||
|
||||
class CoursePlanWeek(models.Model):
|
||||
_name = 'encoach.course.plan.week'
|
||||
_description = 'Course Plan Week'
|
||||
_order = 'week_number asc, id asc'
|
||||
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
week_number = fields.Integer(required=True)
|
||||
date_label = fields.Char(
|
||||
help='Human-readable date range, e.g. "7-11 Sep. 2025".',
|
||||
)
|
||||
unit = fields.Char(help='Textbook unit / theme for the week.')
|
||||
focus = fields.Char(help='Short focus headline for the week.')
|
||||
items_json = fields.Text(
|
||||
help='JSON array of per-skill rows for this week: '
|
||||
'[{skill, outcome_codes: [...], remarks}]. Mirrors the '
|
||||
'GE1 delivery plan table.',
|
||||
)
|
||||
|
||||
material_ids = fields.One2many(
|
||||
'encoach.course.plan.material', 'week_id', string='Materials',
|
||||
)
|
||||
material_count = fields.Integer(compute='_compute_material_count', store=False)
|
||||
|
||||
@api.depends('material_ids')
|
||||
def _compute_material_count(self):
|
||||
for rec in self:
|
||||
rec.material_count = len(rec.material_ids)
|
||||
|
||||
def _loads(self, raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'week_number': self.week_number or 0,
|
||||
'date_label': self.date_label or '',
|
||||
'unit': self.unit or '',
|
||||
'focus': self.focus or '',
|
||||
'items': self._loads(self.items_json, []),
|
||||
'material_count': len(self.material_ids),
|
||||
}
|
||||
|
||||
|
||||
class CoursePlanMaterial(models.Model):
|
||||
_name = 'encoach.course.plan.material'
|
||||
_description = 'Course Plan Teaching Material'
|
||||
_order = 'week_id, skill, id'
|
||||
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
week_id = fields.Many2one(
|
||||
'encoach.course.plan.week', ondelete='cascade', index=True,
|
||||
)
|
||||
week_number = fields.Integer(
|
||||
related='week_id.week_number', store=True, string='Week #',
|
||||
)
|
||||
skill = fields.Selection(SKILL_SELECTION, required=True)
|
||||
material_type = fields.Selection(
|
||||
MATERIAL_TYPE_SELECTION, required=True, default='other',
|
||||
)
|
||||
title = fields.Char(required=True)
|
||||
summary = fields.Text(
|
||||
help='Short blurb — purpose / learning outcomes targeted / how to use.',
|
||||
)
|
||||
body_json = fields.Text(
|
||||
help='Structured payload. Shape depends on material_type: '
|
||||
'reading_text → {text, questions[]}, '
|
||||
'listening_script → {script, comprehension_questions[]}, '
|
||||
'grammar_lesson → {explanation, examples[], practice[]}, etc.',
|
||||
)
|
||||
body_text = fields.Text(
|
||||
help='Plain-text rendering for easy preview / copy-paste when the '
|
||||
'structured body is not needed.',
|
||||
)
|
||||
|
||||
def _loads(self, raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'week_id': self.week_id.id if self.week_id else None,
|
||||
'week_number': self.week_number or 0,
|
||||
'skill': self.skill or '',
|
||||
'material_type': self.material_type or 'other',
|
||||
'title': self.title or '',
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
|
||||
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -1,2 +1,3 @@
|
||||
from .english_pipeline import EnglishPipeline
|
||||
from .ielts_pipeline import IeltsPipeline
|
||||
from .course_plan_pipeline import CoursePlanPipeline
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
"""Course plan generation pipeline.
|
||||
|
||||
Two public entry points:
|
||||
|
||||
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
|
||||
duration, skill coverage, grammar focus, resources), produce a full
|
||||
curriculum outline and persist it as an
|
||||
:py:class:`encoach.course.plan` record, with one
|
||||
:py:class:`encoach.course.plan.week` row per planned week.
|
||||
|
||||
* :py:meth:`generate_week_materials` — given an existing plan and a
|
||||
week number, produce the actual teaching content for that week
|
||||
(reading text, listening script, speaking prompts, grammar mini-lesson
|
||||
+ practice, writing prompt, vocabulary list) and persist each as an
|
||||
:py:class:`encoach.course.plan.material` row.
|
||||
|
||||
We deliberately ask the LLM to return strict JSON and then normalise it
|
||||
server-side — the frontend gets a stable shape no matter how loose the
|
||||
model's output is. Any parse failure is swallowed and reported back
|
||||
through the standard error channel so the caller can retry without the
|
||||
server crashing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
# AgentRuntime is the LangGraph-backed engine. When the feature flag
|
||||
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
|
||||
# the matching key is configured, the pipeline routes through the agent
|
||||
# instead of calling OpenAIService directly. This keeps the existing
|
||||
# fall-back path so the pipeline still works if the agent layer is broken
|
||||
# or being upgraded.
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
|
||||
except ImportError: # pragma: no cover - optional dep
|
||||
AgentRuntime = None
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# JSON schema we coax the LLM into following. Keeping this as a prompt
|
||||
# string (rather than an OpenAI function call) makes it portable if the
|
||||
# underlying `chat_json` implementation ever changes providers.
|
||||
_PLAN_JSON_HINT = """
|
||||
Return JSON with exactly this shape:
|
||||
{
|
||||
"description": "<2-4 sentence course description incl. CEFR>",
|
||||
"objectives": ["<overall course objective>", ...],
|
||||
"outcomes": {
|
||||
"reading": [{"code": "RLO1", "description": "..."}, ...],
|
||||
"writing": [{"code": "WLO1", "description": "..."}, ...],
|
||||
"listening": [{"code": "LLO1", "description": "..."}, ...],
|
||||
"speaking": [{"code": "SLO1", "description": "..."}, ...],
|
||||
"vocabulary": [{"code": "VLO1", "description": "..."}, ...],
|
||||
"grammar": [{"code": "GLO1", "description": "..."}, ...]
|
||||
},
|
||||
"grammar": [
|
||||
{"code": "GT1", "label": "Present tense",
|
||||
"sub_items": ["present simple", "present continuous"]},
|
||||
...
|
||||
],
|
||||
"assessment": {
|
||||
"continuous_assessment": {"total_weight": 50, "components":
|
||||
[{"name":"MTE","weight":30}, {"name":"Oral Presentation","weight":10}, ...]},
|
||||
"final_exam": {"total_weight": 50}
|
||||
},
|
||||
"resources": [
|
||||
{"type": "textbook", "citation": "..."},
|
||||
{"type": "stm", "citation": "..."}
|
||||
],
|
||||
"weeks": [
|
||||
{
|
||||
"week_number": 1,
|
||||
"date_label": "7-11 Sep. 2025",
|
||||
"unit": "One",
|
||||
"focus": "Personal introductions, simple present",
|
||||
"items": [
|
||||
{"skill": "reading", "outcome_codes": ["RLO1","RLO2"], "remarks": "..."},
|
||||
{"skill": "writing", "outcome_codes": ["WLO1","WLO2"], "remarks": "..."},
|
||||
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
|
||||
{"skill": "speaking", "outcome_codes": ["SLO1","SLO2"], "remarks": ""},
|
||||
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""}
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
Use the exact outcome codes across `outcomes` and `weeks[*].items[*].outcome_codes`.
|
||||
"""
|
||||
|
||||
|
||||
_WEEK_JSON_HINT = """
|
||||
Return JSON with exactly this shape:
|
||||
{
|
||||
"materials": [
|
||||
{
|
||||
"skill": "reading",
|
||||
"material_type": "reading_text",
|
||||
"title": "...",
|
||||
"summary": "1-2 sentence teacher note",
|
||||
"body": {
|
||||
"text": "<reading passage ~350-450 words>",
|
||||
"questions": [
|
||||
{"q": "...", "type": "multiple_choice",
|
||||
"options": ["A","B","C","D"], "answer": "A"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "listening",
|
||||
"material_type": "listening_script",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"script": "<3-4 minute dialogue or monologue>",
|
||||
"comprehension_questions": [
|
||||
{"q": "...", "answer": "..."}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "speaking",
|
||||
"material_type": "speaking_prompt",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"prompts": ["...", "..."],
|
||||
"useful_language": ["..."]
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "writing",
|
||||
"material_type": "writing_prompt",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"prompt": "...",
|
||||
"word_count": 150,
|
||||
"model_paragraph": "..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "grammar",
|
||||
"material_type": "grammar_lesson",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"explanation": "...",
|
||||
"examples": ["...","..."],
|
||||
"practice": [
|
||||
{"q":"...", "answer":"..."}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"skill": "vocabulary",
|
||||
"material_type": "vocabulary_list",
|
||||
"title": "...",
|
||||
"summary": "...",
|
||||
"body": {
|
||||
"words": [
|
||||
{"term":"...", "pos":"n.", "definition":"...", "example":"..."}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Only include skills present in the week's items list.
|
||||
"""
|
||||
|
||||
|
||||
class CoursePlanPipeline:
|
||||
"""Wrap the LLM call, normalise the JSON, persist the result."""
|
||||
|
||||
def __init__(self, env, *, language="en"):
|
||||
self.env = env
|
||||
self.language = language
|
||||
if OpenAIService is None:
|
||||
raise RuntimeError(
|
||||
"OpenAIService is not available — encoach_ai is not installed."
|
||||
)
|
||||
self.ai = OpenAIService(env, language=language)
|
||||
# Decide once per instance whether to route through the LangGraph
|
||||
# AgentRuntime or fall back to the direct chat_json path.
|
||||
self._use_agent = self._resolve_agent_flag(env)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_flag(env):
|
||||
if AgentRuntime is None:
|
||||
return False
|
||||
try:
|
||||
raw = env["ir.config_parameter"].sudo().get_param(
|
||||
"encoach_ai.use_langgraph_runtime", "True",
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
def _agent(self, key):
|
||||
"""Lazily build an AgentRuntime for ``key`` if the flag allows it."""
|
||||
if not self._use_agent or AgentRuntime is None:
|
||||
return None
|
||||
try:
|
||||
return AgentRuntime.from_key(self.env, key, language=self.language)
|
||||
except Exception:
|
||||
_logger.exception("AgentRuntime.from_key(%s) failed", key)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plan-level generation
|
||||
# ------------------------------------------------------------------
|
||||
def generate_plan(self, brief):
|
||||
"""Generate the full course plan header + week rows from a brief.
|
||||
|
||||
:param brief: ``dict`` with optional keys:
|
||||
title, cefr_level, total_weeks, contact_hours_per_week,
|
||||
skills_division, grammar_focus (list), resources (list),
|
||||
learner_profile (string), notes (string), course_id (int),
|
||||
language (string ISO-639-1).
|
||||
:returns: ``encoach.course.plan`` record.
|
||||
"""
|
||||
title = (brief.get('title') or '').strip() or 'Untitled course'
|
||||
cefr = (brief.get('cefr_level') or 'a2').lower()
|
||||
total_weeks = int(brief.get('total_weeks') or 12)
|
||||
contact_hours = int(brief.get('contact_hours_per_week') or 18)
|
||||
skills_division = (brief.get('skills_division') or '').strip()
|
||||
grammar_focus = brief.get('grammar_focus') or []
|
||||
resources = brief.get('resources') or []
|
||||
learner_profile = (brief.get('learner_profile') or '').strip()
|
||||
notes = (brief.get('notes') or '').strip()
|
||||
|
||||
system_msg = (
|
||||
"You are an expert English language curriculum designer. "
|
||||
"You produce structured course outlines suitable for a "
|
||||
"general foundation programme. You MUST return valid JSON "
|
||||
"that matches the schema in the user prompt exactly. Never "
|
||||
"wrap the JSON in prose."
|
||||
)
|
||||
user_msg = (
|
||||
f"Design a {total_weeks}-week course titled \"{title}\" at "
|
||||
f"CEFR {cefr.upper()} with approximately {contact_hours} "
|
||||
f"contact hours per week.\n"
|
||||
f"Skills division: {skills_division or 'auto'}.\n"
|
||||
f"Grammar focus: {', '.join(grammar_focus) or 'auto'}.\n"
|
||||
f"Resources to reference: "
|
||||
f"{'; '.join(resources) if resources else 'none'}.\n"
|
||||
f"Learner profile: {learner_profile or 'mixed L1 adult learners'}.\n"
|
||||
f"Additional notes: {notes or 'none'}.\n\n"
|
||||
+ _PLAN_JSON_HINT
|
||||
)
|
||||
|
||||
# Prefer the LangGraph agent if one is configured; fall back to the
|
||||
# direct OpenAI call so the feature still works if the agent table
|
||||
# is empty or the runtime fails to compile.
|
||||
content = self._invoke_agent_or_chat(
|
||||
agent_key="course_planner",
|
||||
system_msg=system_msg,
|
||||
user_msg=user_msg,
|
||||
variables={
|
||||
"title": title,
|
||||
"cefr_level": cefr,
|
||||
"total_weeks": total_weeks,
|
||||
},
|
||||
temperature=0.4,
|
||||
max_tokens=4096,
|
||||
action="course_plan.generate",
|
||||
)
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
|
||||
plan_vals = {
|
||||
'name': title,
|
||||
'cefr_level': cefr if cefr in {
|
||||
'pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'
|
||||
} else 'a2',
|
||||
'total_weeks': total_weeks,
|
||||
'contact_hours_per_week': contact_hours,
|
||||
'skills_division': skills_division,
|
||||
'description': (content.get('description') or '').strip(),
|
||||
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
|
||||
'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
|
||||
'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
|
||||
'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
|
||||
'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
|
||||
'brief_json': json.dumps(brief, ensure_ascii=False),
|
||||
'status': 'generated',
|
||||
}
|
||||
if brief.get('course_id'):
|
||||
try:
|
||||
plan_vals['course_id'] = int(brief['course_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
plan = self.env['encoach.course.plan'].sudo().create(plan_vals)
|
||||
|
||||
# Create week rows.
|
||||
Week = self.env['encoach.course.plan.week'].sudo()
|
||||
for w in content.get('weeks') or []:
|
||||
try:
|
||||
Week.create({
|
||||
'plan_id': plan.id,
|
||||
'week_number': int(w.get('week_number') or 0),
|
||||
'date_label': (w.get('date_label') or '').strip(),
|
||||
'unit': (w.get('unit') or '').strip(),
|
||||
'focus': (w.get('focus') or '').strip(),
|
||||
'items_json': json.dumps(w.get('items') or [], ensure_ascii=False),
|
||||
})
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
_logger.warning("Skipping bad week row: %s", exc)
|
||||
return plan
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Week-level material generation
|
||||
# ------------------------------------------------------------------
|
||||
def generate_week_materials(self, plan_id, week_number):
|
||||
"""Generate teaching materials for one week and persist them.
|
||||
|
||||
Any existing materials for the same plan_id + week_number are
|
||||
replaced — callers that want to keep old versions should copy
|
||||
them before re-running.
|
||||
"""
|
||||
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
raise ValueError('Plan not found')
|
||||
week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
|
||||
if not week:
|
||||
raise ValueError(f'Week {week_number} not found on plan {plan_id}')
|
||||
week = week[0]
|
||||
|
||||
outcomes = plan._loads(plan.outcomes_json, {})
|
||||
items = week._loads(week.items_json, [])
|
||||
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-"
|
||||
"to-use classroom materials. Your output MUST be valid JSON "
|
||||
"matching the schema in the user prompt. Keep reading texts "
|
||||
"close to the target word count for the CEFR level. Keep "
|
||||
"listening scripts natural and conversational. All tasks "
|
||||
"must target the outcome codes supplied."
|
||||
)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue (for looking up codes):\n"
|
||||
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
|
||||
+ _WEEK_JSON_HINT
|
||||
)
|
||||
|
||||
content = self._invoke_agent_or_chat(
|
||||
agent_key="course_week_materials",
|
||||
system_msg=system_msg,
|
||||
user_msg=user_msg,
|
||||
variables={
|
||||
"course": plan.name,
|
||||
"cefr_level": (plan.cefr_level or "").lower(),
|
||||
"week_number": week.week_number,
|
||||
},
|
||||
temperature=0.6,
|
||||
max_tokens=6000,
|
||||
action="course_plan.generate_week",
|
||||
)
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
|
||||
# Wipe any previous materials for this week so re-generating is
|
||||
# idempotent and we never accumulate duplicates.
|
||||
existing = self.env['encoach.course.plan.material'].sudo().search([
|
||||
('plan_id', '=', plan.id), ('week_id', '=', week.id),
|
||||
])
|
||||
if existing:
|
||||
existing.unlink()
|
||||
|
||||
Material = self.env['encoach.course.plan.material'].sudo()
|
||||
created = []
|
||||
for m in content.get('materials') or []:
|
||||
try:
|
||||
rec = Material.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'skill': (m.get('skill') or 'integrated').strip().lower(),
|
||||
'material_type': (m.get('material_type') or 'other').strip(),
|
||||
'title': (m.get('title') or '').strip() or 'Untitled',
|
||||
'summary': (m.get('summary') or '').strip(),
|
||||
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
|
||||
'body_text': self._flatten_body(m.get('body') or {}),
|
||||
})
|
||||
created.append(rec)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
_logger.warning("Skipping bad material row: %s", exc)
|
||||
return created
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
def _chat_json(self, messages, **kwargs):
|
||||
"""Best-effort wrapper around ``ai.chat_json``.
|
||||
|
||||
The underlying service may raise (network, invalid key, etc.),
|
||||
or return a dict with an ``error`` field when content moderation
|
||||
rejects the request. We normalise both to a dict so callers can
|
||||
just check ``'error' in result``.
|
||||
"""
|
||||
try:
|
||||
return self.ai.chat_json(messages, **kwargs)
|
||||
except Exception as exc:
|
||||
_logger.exception("Course plan AI call failed")
|
||||
return {'error': str(exc)}
|
||||
|
||||
def _invoke_agent_or_chat(self, *, agent_key, system_msg, user_msg,
|
||||
variables, temperature, max_tokens, action):
|
||||
"""Route through AgentRuntime when available; fall back to chat_json.
|
||||
|
||||
Both branches return the same shape — a dict the caller can
|
||||
``json.loads``-style consume — so the rest of the pipeline doesn't
|
||||
change. We pass ``user_msg`` as the payload because the agent's own
|
||||
system prompt is normally the one used; only when the agent is
|
||||
missing do we pass the inline ``system_msg``.
|
||||
"""
|
||||
runtime = self._agent(agent_key)
|
||||
if runtime is not None:
|
||||
# The pipeline owns the JSON schema for backward-compat, so we
|
||||
# forward the schema-bearing user message into the agent. The
|
||||
# agent's stored system prompt covers the role/rules; we add
|
||||
# the schema as ``extra_system`` so it's heeded but auditable.
|
||||
final = runtime.invoke(
|
||||
variables=variables,
|
||||
payload=user_msg,
|
||||
extra_system=system_msg,
|
||||
)
|
||||
if final.get("error"):
|
||||
_logger.warning(
|
||||
"agent %s failed (%s); falling back to direct chat_json",
|
||||
agent_key, final.get("error"),
|
||||
)
|
||||
else:
|
||||
output = final.get("output")
|
||||
if isinstance(output, dict):
|
||||
return output
|
||||
# Text output — try parsing once, otherwise fall back.
|
||||
try:
|
||||
return json.loads(final.get("output_raw") or "{}")
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback path: plain OpenAI call (legacy).
|
||||
return self._chat_json(
|
||||
[
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
action=action,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _flatten_body(body):
|
||||
"""Produce a plain-text dump of a material body for quick preview.
|
||||
|
||||
Not every shape is predictable (the model sometimes inserts
|
||||
unusual keys), so we do a shallow walk and join string values
|
||||
with newlines.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return ''
|
||||
lines = []
|
||||
for key, value in body.items():
|
||||
if isinstance(value, str):
|
||||
lines.append(f"{key}: {value}")
|
||||
elif isinstance(value, list):
|
||||
lines.append(f"{key}:")
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
lines.append(f" - {item}")
|
||||
elif isinstance(item, dict):
|
||||
parts = []
|
||||
for k, v in item.items():
|
||||
if isinstance(v, (str, int, float)):
|
||||
parts.append(f"{k}={v}")
|
||||
if parts:
|
||||
lines.append(" - " + ", ".join(parts))
|
||||
elif isinstance(value, dict):
|
||||
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
|
||||
return "\n".join(lines)
|
||||
@@ -1 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import utils # exposes odoo.addons.encoach_api.utils.cache
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
'external_dependencies': {
|
||||
'python': ['PyJWT'],
|
||||
},
|
||||
'data': [],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
from . import base
|
||||
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 time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
@@ -13,6 +33,40 @@ from .base import (
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_TTL_SECONDS = 3600 # 1 hour
|
||||
REFRESH_TTL_SECONDS = 7 * 86400 # 7 days
|
||||
|
||||
|
||||
def _issue_tokens(user, *, user_agent=None, remote_ip=None):
|
||||
"""Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple.
|
||||
|
||||
Persisting the refresh token is the caller's job — they get the ``jti``
|
||||
back so they can insert exactly one row.
|
||||
"""
|
||||
secret = _get_jwt_secret()
|
||||
now = int(time.time())
|
||||
access_token = pyjwt.encode(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + ACCESS_TTL_SECONDS,
|
||||
},
|
||||
secret, algorithm="HS256",
|
||||
)
|
||||
refresh_jti = uuid.uuid4().hex
|
||||
refresh_token = pyjwt.encode(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"type": "refresh",
|
||||
"jti": refresh_jti,
|
||||
"iat": now,
|
||||
"exp": now + REFRESH_TTL_SECONDS,
|
||||
},
|
||||
secret, algorithm="HS256",
|
||||
)
|
||||
return access_token, refresh_token, refresh_jti
|
||||
|
||||
|
||||
class EncoachAuthController(http.Controller):
|
||||
|
||||
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
|
||||
if not login or not password:
|
||||
return _error_response('login and password are required', 400)
|
||||
|
||||
# Odoo 19: session.authenticate(env, credential_dict)
|
||||
credential = {
|
||||
'type': 'password',
|
||||
'login': login,
|
||||
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
|
||||
|
||||
user = request.env['res.users'].sudo().browse(uid)
|
||||
|
||||
# Generate JWT token
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
return _error_response('JWT not configured on server', 500)
|
||||
|
||||
token = pyjwt.encode(
|
||||
{'user_id': user.id, 'exp': int(time.time()) + 86400},
|
||||
secret, algorithm='HS256',
|
||||
)
|
||||
# User-Agent / IP are best-effort — some proxies strip them; we
|
||||
# record whatever we get but never refuse login because they're
|
||||
# missing.
|
||||
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
|
||||
ip = request.httprequest.remote_addr if request.httprequest else ''
|
||||
|
||||
access_token, refresh_token, refresh_jti = _issue_tokens(
|
||||
user, user_agent=ua, remote_ip=ip,
|
||||
)
|
||||
request.env['encoach.jwt.token'].sudo().create({
|
||||
'jti': refresh_jti,
|
||||
'user_id': user.id,
|
||||
'issued_at': fields.Datetime.now(),
|
||||
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
|
||||
'user_agent': (ua or '')[:255],
|
||||
'remote_ip': (ip or '')[:64],
|
||||
})
|
||||
|
||||
# Get permissions
|
||||
permissions = []
|
||||
if hasattr(user, 'get_all_permissions'):
|
||||
permissions = user.get_all_permissions().mapped('code')
|
||||
|
||||
# Update last login
|
||||
user.write({'last_login': fields.Datetime.now()})
|
||||
|
||||
return _json_response({
|
||||
'token': token,
|
||||
'token': access_token, # legacy name (frontend fallback)
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'expires_in': ACCESS_TTL_SECONDS,
|
||||
'refresh_expires_in': REFRESH_TTL_SECONDS,
|
||||
'token_type': 'Bearer',
|
||||
'user': self._user_to_dict(user),
|
||||
'permissions': permissions,
|
||||
})
|
||||
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
|
||||
_logger.exception('login failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/auth/refresh
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/auth/refresh', type='http', auth='public',
|
||||
methods=['POST'], csrf=False)
|
||||
def refresh(self, **kw):
|
||||
"""Rotate a refresh token into a fresh access+refresh pair.
|
||||
|
||||
Consumes (revokes) the presented refresh token and issues new ones.
|
||||
This is the *only* place a refresh token is ever accepted, so replay
|
||||
attempts against other endpoints fail at the access-token layer.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
token = body.get('refresh_token') or ''
|
||||
if not token:
|
||||
return _error_response('refresh_token is required', 400)
|
||||
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
return _error_response('JWT not configured on server', 500)
|
||||
|
||||
try:
|
||||
claims = pyjwt.decode(token, secret, algorithms=['HS256'])
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
return _error_response('refresh_token expired', 401)
|
||||
except pyjwt.InvalidTokenError:
|
||||
return _error_response('refresh_token invalid', 401)
|
||||
|
||||
if claims.get('type') != 'refresh':
|
||||
return _error_response('wrong token type', 401)
|
||||
|
||||
jti = claims.get('jti')
|
||||
user_id = claims.get('user_id')
|
||||
if not jti or not user_id:
|
||||
return _error_response('refresh_token malformed', 401)
|
||||
|
||||
Token = request.env['encoach.jwt.token'].sudo()
|
||||
row = Token.search([
|
||||
('jti', '=', jti),
|
||||
('user_id', '=', user_id),
|
||||
('revoked', '=', False),
|
||||
], limit=1)
|
||||
if not row:
|
||||
# Either already rotated (possible replay) or never existed.
|
||||
# Revoke every active token for that user as a defensive
|
||||
# measure — stolen refresh tokens shouldn't buy multiple
|
||||
# rotations.
|
||||
Token.search([
|
||||
('user_id', '=', user_id),
|
||||
('revoked', '=', False),
|
||||
]).write({'revoked': True})
|
||||
return _error_response('refresh_token revoked', 401)
|
||||
|
||||
user = request.env['res.users'].sudo().browse(user_id)
|
||||
if not user.exists() or not user.active:
|
||||
return _error_response('user disabled', 401)
|
||||
|
||||
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
|
||||
ip = request.httprequest.remote_addr if request.httprequest else ''
|
||||
access_token, refresh_token, refresh_jti = _issue_tokens(
|
||||
user, user_agent=ua, remote_ip=ip,
|
||||
)
|
||||
row.write({'revoked': True, 'last_used_at': fields.Datetime.now()})
|
||||
Token.create({
|
||||
'jti': refresh_jti,
|
||||
'user_id': user.id,
|
||||
'issued_at': fields.Datetime.now(),
|
||||
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
|
||||
'user_agent': (ua or '')[:255],
|
||||
'remote_ip': (ip or '')[:64],
|
||||
})
|
||||
return _json_response({
|
||||
'access_token': access_token,
|
||||
'token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'expires_in': ACCESS_TTL_SECONDS,
|
||||
'refresh_expires_in': REFRESH_TTL_SECONDS,
|
||||
'token_type': 'Bearer',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('refresh failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/user (returns current authenticated user)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
|
||||
@http.route('/api/logout', type='http', auth='public',
|
||||
methods=['POST'], csrf=False)
|
||||
def logout(self, **kw):
|
||||
# JWT is stateless — client clears token. Server just returns OK.
|
||||
"""Revoke the presented refresh token (if any) and return OK.
|
||||
|
||||
Access tokens remain stateless; they expire naturally within an hour.
|
||||
Clients that care about immediate lockout should also delete the
|
||||
access token from local storage when they call this endpoint.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
token = body.get('refresh_token') or ''
|
||||
if token:
|
||||
secret = _get_jwt_secret()
|
||||
if secret:
|
||||
try:
|
||||
claims = pyjwt.decode(
|
||||
token, secret, algorithms=['HS256'],
|
||||
options={'verify_exp': False},
|
||||
)
|
||||
jti = claims.get('jti')
|
||||
if jti:
|
||||
request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti)
|
||||
except pyjwt.InvalidTokenError:
|
||||
# Invalid tokens can't be revoked but also can't be
|
||||
# reused, so this is effectively a no-op.
|
||||
pass
|
||||
except Exception:
|
||||
_logger.exception('logout cleanup failed')
|
||||
return _json_response({'ok': True})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
@@ -9,14 +10,75 @@ from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_ID_HEADER = "X-Request-Id"
|
||||
|
||||
|
||||
def _get_or_create_request_id():
|
||||
"""Return the X-Request-Id for the current request, minting one if absent.
|
||||
|
||||
The value is cached on ``request`` so the same id is reused for every log
|
||||
line emitted during the request lifecycle (dispatcher, controller,
|
||||
post-dispatch). Call sites should prefer :func:`current_request_id`.
|
||||
"""
|
||||
existing = getattr(request, "_encoach_request_id", None)
|
||||
if existing:
|
||||
return existing
|
||||
header_val = request.httprequest.headers.get(REQUEST_ID_HEADER)
|
||||
req_id = (header_val or uuid.uuid4().hex)[:64]
|
||||
try:
|
||||
request._encoach_request_id = req_id
|
||||
except Exception:
|
||||
pass
|
||||
return req_id
|
||||
|
||||
|
||||
def current_request_id():
|
||||
"""Best-effort accessor for the active request id (None outside a request)."""
|
||||
try:
|
||||
return getattr(request, "_encoach_request_id", None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _log_json(level, event, **fields):
|
||||
"""Emit a single structured JSON log line, enriched with the request id.
|
||||
|
||||
Keeping all non-interactive logs JSON-formatted lets ops scrape them with
|
||||
Loki / OpenSearch without brittle regexes. Falls back silently if the
|
||||
logging backend rejects the payload.
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
"ts": round(time.time(), 3),
|
||||
"event": event,
|
||||
"rid": current_request_id(),
|
||||
}
|
||||
payload.update(fields)
|
||||
_logger.log(level, json.dumps(payload, default=str, ensure_ascii=False))
|
||||
except Exception:
|
||||
_logger.log(level, "%s %s", event, fields)
|
||||
|
||||
_jwt_secret_cache = {"secret": None, "ts": 0}
|
||||
_JWT_SECRET_TTL = 300
|
||||
# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation
|
||||
# via ir.config_parameter propagates quickly across all workers without
|
||||
# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21.
|
||||
_JWT_SECRET_TTL = 30
|
||||
|
||||
_user_exists_cache = {}
|
||||
_USER_CACHE_TTL = 60
|
||||
_USER_CACHE_MAX = 200
|
||||
|
||||
|
||||
def invalidate_jwt_secret_cache():
|
||||
"""Invalidate the per-worker JWT secret cache immediately.
|
||||
|
||||
Called by ``encoach.auth.settings`` whenever the admin rotates the secret
|
||||
so that subsequent requests pick up the new value on the next read.
|
||||
"""
|
||||
_jwt_secret_cache["secret"] = None
|
||||
_jwt_secret_cache["ts"] = 0
|
||||
|
||||
|
||||
def _get_jwt_secret():
|
||||
now = time.time()
|
||||
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
|
||||
@@ -48,6 +110,12 @@ def validate_token():
|
||||
return None
|
||||
except pyjwt.InvalidTokenError:
|
||||
return None
|
||||
# Refresh tokens are only accepted by /api/auth/refresh. Presenting one as
|
||||
# a Bearer on any other endpoint is treated as unauthenticated so a
|
||||
# leaked refresh token never buys API access on its own.
|
||||
token_type = payload.get("type")
|
||||
if token_type and token_type != "access":
|
||||
return None
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
@@ -70,26 +138,95 @@ def validate_token():
|
||||
|
||||
|
||||
def jwt_required(func):
|
||||
"""Decorator that validates the JWT token and sets request.env user context."""
|
||||
"""Decorator that validates the JWT token and sets request.env user context.
|
||||
|
||||
Also handles per-request observability plumbing:
|
||||
|
||||
- Assigns (or honours) an ``X-Request-Id`` so every log line, downstream
|
||||
service call, and response can be correlated back to the same request.
|
||||
- Emits a structured ``api.request.start`` / ``api.request.end`` log pair
|
||||
with duration in ms, status code, user id, route, and method — powering
|
||||
the P2.3 metrics pipeline once it lands without any extra wiring.
|
||||
- Echoes the request id back to the caller via the ``X-Request-Id``
|
||||
response header.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
req_id = _get_or_create_request_id()
|
||||
route = request.httprequest.path
|
||||
method = request.httprequest.method
|
||||
t0 = time.time()
|
||||
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _error_response("Authentication required", status=401)
|
||||
_log_json(logging.INFO, "api.request.unauthorized",
|
||||
route=route, method=method, status=401)
|
||||
resp = _error_response("Authentication required", status=401)
|
||||
try:
|
||||
resp.headers[REQUEST_ID_HEADER] = req_id
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
request.update_env(user=user.id)
|
||||
return func(*args, **kwargs)
|
||||
_log_json(logging.INFO, "api.request.start",
|
||||
route=route, method=method, user_id=user.id)
|
||||
try:
|
||||
resp = func(*args, **kwargs)
|
||||
status = getattr(resp, "status_code", 200)
|
||||
duration_ms = int((time.time() - t0) * 1000)
|
||||
_log_json(logging.INFO, "api.request.end",
|
||||
route=route, method=method, user_id=user.id,
|
||||
status=status, duration_ms=duration_ms)
|
||||
try:
|
||||
resp.headers[REQUEST_ID_HEADER] = req_id
|
||||
except Exception:
|
||||
pass
|
||||
_record_metric(route, status, duration_ms)
|
||||
return resp
|
||||
except Exception as exc:
|
||||
duration_ms = int((time.time() - t0) * 1000)
|
||||
_log_json(logging.ERROR, "api.request.error",
|
||||
route=route, method=method, user_id=user.id,
|
||||
duration_ms=duration_ms,
|
||||
error=type(exc).__name__, message=str(exc)[:500])
|
||||
_record_metric(route, 500, duration_ms)
|
||||
raise
|
||||
return wrapper
|
||||
|
||||
|
||||
def _record_metric(route, status, duration_ms):
|
||||
"""Soft import of the metrics recorder so this module has no hard cycle."""
|
||||
try:
|
||||
from odoo.addons.encoach_api.controllers.openapi import record_request
|
||||
record_request(route, status, duration_ms)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return request.make_json_response(data, status=status)
|
||||
resp = request.make_json_response(data, status=status)
|
||||
try:
|
||||
rid = current_request_id()
|
||||
if rid:
|
||||
resp.headers[REQUEST_ID_HEADER] = rid
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
def _error_response(message, status=400, code=None):
|
||||
body = {"error": message}
|
||||
if code:
|
||||
body["code"] = code
|
||||
return request.make_json_response(body, status=status)
|
||||
resp = request.make_json_response(body, status=status)
|
||||
try:
|
||||
rid = current_request_id()
|
||||
if rid:
|
||||
resp.headers[REQUEST_ID_HEADER] = rid
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
def _get_json_body():
|
||||
@@ -99,6 +236,31 @@ def _get_json_body():
|
||||
return {}
|
||||
|
||||
|
||||
def paginated_envelope(items, *, total=None, page=None, size=None, extra=None):
|
||||
"""Canonical ``{items, total, page, size}`` envelope.
|
||||
|
||||
This is the platform-wide pagination shape — prefer returning the output of
|
||||
this helper from any new list endpoint. Legacy keys (``data``, ``results``,
|
||||
and custom aliases such as ``students`` / ``subjects``) may be added via
|
||||
the ``extra`` dict for transitional backward compatibility with the
|
||||
frontend services that still read those keys; new code must read ``items``.
|
||||
"""
|
||||
items = list(items or [])
|
||||
envelope = {
|
||||
"items": items,
|
||||
"total": int(total if total is not None else len(items)),
|
||||
}
|
||||
if page is not None:
|
||||
envelope["page"] = int(page)
|
||||
if size is not None:
|
||||
envelope["size"] = int(size)
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
if k not in envelope:
|
||||
envelope[k] = v
|
||||
return envelope
|
||||
|
||||
|
||||
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
|
||||
"""Paginate an Odoo model search or extract params from a kwargs dict.
|
||||
|
||||
|
||||
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 approval_workflows
|
||||
from . import entities
|
||||
from . import exam_session
|
||||
from . import review_workflow
|
||||
|
||||
@@ -57,17 +57,33 @@ def _ser_workflow(wf):
|
||||
|
||||
def _ser_request(req):
|
||||
stage = req.current_stage_id
|
||||
# Resolve target record so the UI can show "what am I approving?"
|
||||
# rather than an opaque id.
|
||||
target_name = ''
|
||||
target_status = ''
|
||||
if req.res_model and req.res_id:
|
||||
try:
|
||||
target = request.env[req.res_model].sudo().browse(req.res_id)
|
||||
if target.exists():
|
||||
target_name = getattr(target, 'title', '') or getattr(target, 'name', '') or ''
|
||||
target_status = getattr(target, 'status', '') or ''
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
return {
|
||||
'id': req.id,
|
||||
'workflow_id': req.workflow_id.id or None,
|
||||
'workflow_name': req.workflow_id.name if req.workflow_id else '',
|
||||
'res_model': req.res_model or '',
|
||||
'res_id': req.res_id or 0,
|
||||
'target_name': target_name,
|
||||
'target_status': target_status,
|
||||
'state': req.state or 'draft',
|
||||
'requester_id': req.requester_id.id or None,
|
||||
'requester_name': req.requester_id.name if req.requester_id else '',
|
||||
'current_stage_id': stage.id or None,
|
||||
'current_stage_sequence': stage.sequence if stage else None,
|
||||
'current_stage_approver_id': stage.approver_id.id if stage and stage.approver_id else None,
|
||||
'current_stage_approver_name': stage.approver_id.name if stage and stage.approver_id else '',
|
||||
'bypass_reason': req.bypass_reason or '',
|
||||
'created_at': req.created_at.isoformat() if req.created_at else None,
|
||||
}
|
||||
@@ -234,6 +250,16 @@ class ApprovalWorkflowController(http.Controller):
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_requests(self, **kw):
|
||||
"""List approval requests.
|
||||
|
||||
Query params:
|
||||
* ``state`` — filter by request state
|
||||
* ``workflow_id`` — filter by workflow
|
||||
* ``mine=1`` — only requests currently awaiting the calling user
|
||||
(current_stage.approver_id == me). Used by the "My approvals"
|
||||
queue so approvers see exactly what's on their plate.
|
||||
* ``requester=1`` — only requests the calling user submitted
|
||||
"""
|
||||
try:
|
||||
M = request.env['encoach.approval.request'].sudo()
|
||||
domain = []
|
||||
@@ -241,6 +267,14 @@ class ApprovalWorkflowController(http.Controller):
|
||||
domain.append(('state', '=', kw['state']))
|
||||
if kw.get('workflow_id'):
|
||||
domain.append(('workflow_id', '=', int(kw['workflow_id'])))
|
||||
if kw.get('mine') in ('1', 'true', True):
|
||||
uid = request.env.user.id
|
||||
domain += [
|
||||
('current_stage_id.approver_id', '=', uid),
|
||||
('state', 'in', ('draft', 'in_progress')),
|
||||
]
|
||||
if kw.get('requester') in ('1', 'true', True):
|
||||
domain.append(('requester_id', '=', request.env.user.id))
|
||||
recs = M.search(domain, order='created_at desc, id desc')
|
||||
items = [_ser_request(r) for r in recs]
|
||||
return _json_response({
|
||||
@@ -256,36 +290,56 @@ class ApprovalWorkflowController(http.Controller):
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def approve_request(self, req_id, **kw):
|
||||
"""Approve the current stage or finalize the request.
|
||||
|
||||
Wrapped in a ``SAVEPOINT`` so stage + request writes commit or roll back
|
||||
atomically. The savepoint is released only after both writes succeed,
|
||||
preventing partial states (e.g. stage marked ``approved`` while request
|
||||
still ``in_progress``) that were possible with the previous
|
||||
non-transactional implementation.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
||||
if not req_rec.exists():
|
||||
return _error_response('Request not found', 404)
|
||||
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'approved',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
with request.env.cr.savepoint():
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'approved',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
|
||||
# Move to next stage or finalize.
|
||||
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
|
||||
stage_ids = [s.id for s in stages]
|
||||
try:
|
||||
idx = stage_ids.index(stage.id) if stage else -1
|
||||
except ValueError:
|
||||
idx = -1
|
||||
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
|
||||
stage_ids = [s.id for s in stages]
|
||||
try:
|
||||
idx = stage_ids.index(stage.id) if stage else -1
|
||||
except ValueError:
|
||||
idx = -1
|
||||
|
||||
if 0 <= idx and idx + 1 < len(stage_ids):
|
||||
next_stage = request.env['encoach.approval.stage'].sudo().browse(stage_ids[idx + 1])
|
||||
req_rec.write({
|
||||
'current_stage_id': next_stage.id,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
else:
|
||||
req_rec.write({'state': 'approved'})
|
||||
if 0 <= idx and idx + 1 < len(stage_ids):
|
||||
next_stage = request.env['encoach.approval.stage'].sudo().browse(
|
||||
stage_ids[idx + 1]
|
||||
)
|
||||
req_rec.write({
|
||||
'current_stage_id': next_stage.id,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
else:
|
||||
req_rec.write({'state': 'approved'})
|
||||
# Last stage passed — publish the underlying record.
|
||||
# Today this is always an exam; guard defensively so
|
||||
# other res_models don't raise.
|
||||
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
|
||||
try:
|
||||
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
|
||||
if target.exists() and target.status in ('draft', 'pending_review', 'pending_approval'):
|
||||
target.write({'status': 'published'})
|
||||
except Exception:
|
||||
_logger.exception('failed to publish exam %s on final approval', req_rec.res_id)
|
||||
|
||||
return _json_response({'success': True, 'id': req_id,
|
||||
'state': req_rec.state})
|
||||
@@ -297,19 +351,34 @@ class ApprovalWorkflowController(http.Controller):
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reject_request(self, req_id, **kw):
|
||||
"""Reject a stage and mark the request rejected atomically.
|
||||
|
||||
Uses a ``SAVEPOINT`` so that if the request ``write`` fails after the
|
||||
stage has been updated, Postgres rolls both writes back and the caller
|
||||
sees a clean 500 rather than a half-updated workflow.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
||||
if not req_rec.exists():
|
||||
return _error_response('Request not found', 404)
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'rejected',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
req_rec.write({'state': 'rejected'})
|
||||
with request.env.cr.savepoint():
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'rejected',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
req_rec.write({'state': 'rejected'})
|
||||
# Mark the underlying exam rejected so the author can see it.
|
||||
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
|
||||
try:
|
||||
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
|
||||
if target.exists():
|
||||
target.write({'status': 'rejected'})
|
||||
except Exception:
|
||||
_logger.exception('failed to mark exam %s rejected', req_rec.res_id)
|
||||
return _json_response({'success': True, 'id': req_id,
|
||||
'state': req_rec.state})
|
||||
except Exception as e:
|
||||
@@ -322,18 +391,33 @@ class ApprovalWorkflowController(http.Controller):
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_users(self, **kw):
|
||||
"""Return users eligible to act as approvers.
|
||||
|
||||
Explicitly excludes:
|
||||
* inactive users, ``__system__`` (id=1), the portal template user, and
|
||||
any ``op.student`` mirrored account (``op_student_id`` set)
|
||||
* users with ``user_type='student'`` — students must never be
|
||||
approvers; QA flagged student logins appearing in the picker.
|
||||
"""
|
||||
try:
|
||||
Users = request.env['res.users'].sudo()
|
||||
domain = [('active', '=', True), ('id', '>', 1)]
|
||||
domain = [
|
||||
('active', '=', True),
|
||||
('id', '>', 1),
|
||||
('share', '=', False),
|
||||
'|', ('user_type', '=', False), ('user_type', '!=', 'student'),
|
||||
('op_student_id', '=', False),
|
||||
]
|
||||
search = (kw.get('search') or '').strip()
|
||||
if search:
|
||||
domain += ['|', ('name', 'ilike', search), ('login', 'ilike', search)]
|
||||
recs = Users.search(domain, order='name', limit=100)
|
||||
recs = Users.search(domain, order='name', limit=200)
|
||||
items = [{
|
||||
'id': u.id,
|
||||
'name': u.name or u.login,
|
||||
'login': u.login or '',
|
||||
'email': u.email or '',
|
||||
'user_type': u.user_type or '',
|
||||
} for u in recs]
|
||||
return _json_response({'items': items, 'results': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
|
||||
@@ -142,9 +142,23 @@ class EncoachExamScheduleController(http.Controller):
|
||||
if batch_ids:
|
||||
vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])]
|
||||
|
||||
# IMPORTANT: the frontend's student picker consumes /api/students
|
||||
# which returns `op.student.id` values. `schedule.student_ids` is a
|
||||
# Many2many → res.users, so we must resolve op.student → user_id
|
||||
# before writing. Skipping this step silently linked schedules to
|
||||
# whatever `res.users` row happened to share the same integer id
|
||||
# (e.g. OdooBot), producing "0 assignees" and no visible exam for
|
||||
# the student (bug surfaced during the Apr-2026 QA E2E run).
|
||||
student_ids = body.get('student_ids', [])
|
||||
if student_ids:
|
||||
vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])]
|
||||
resolved_user_ids = self._resolve_student_user_ids(student_ids)
|
||||
if resolved_user_ids:
|
||||
vals['student_ids'] = [(6, 0, resolved_user_ids)]
|
||||
else:
|
||||
_logger.warning(
|
||||
'exam-schedule create: student_ids=%s resolved to 0 users',
|
||||
student_ids,
|
||||
)
|
||||
|
||||
rec = request.env['encoach.exam.schedule'].sudo().create(vals)
|
||||
|
||||
@@ -155,6 +169,46 @@ class EncoachExamScheduleController(http.Controller):
|
||||
_logger.exception('exam-schedule create failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
def _resolve_student_user_ids(self, raw_ids):
|
||||
"""Accept a list of ``op.student`` ids from the UI and return the
|
||||
matching ``res.users`` ids — dropping entries that don't link to a
|
||||
portal user (e.g. a student record created without an Odoo user).
|
||||
|
||||
Falls back to treating any id that doesn't match an op.student row as
|
||||
a raw res.users id, so direct back-office callers posting user ids
|
||||
keep working.
|
||||
"""
|
||||
try:
|
||||
ints = [int(s) for s in raw_ids if s is not None and str(s).strip() != '']
|
||||
except (TypeError, ValueError):
|
||||
_logger.warning('exam-schedule: non-integer student_ids payload %r', raw_ids)
|
||||
return []
|
||||
if not ints:
|
||||
return []
|
||||
|
||||
Student = request.env['op.student'].sudo()
|
||||
Users = request.env['res.users'].sudo()
|
||||
student_recs = Student.search([('id', 'in', ints)])
|
||||
resolved = set()
|
||||
matched_student_ids = set()
|
||||
for s in student_recs:
|
||||
matched_student_ids.add(s.id)
|
||||
user = s.user_id if hasattr(s, 'user_id') else None
|
||||
if user and user.id:
|
||||
resolved.add(user.id)
|
||||
# Any id we couldn't match as an op.student: treat as a direct
|
||||
# res.users id but verify the user exists and is not a system user.
|
||||
leftover = [i for i in ints if i not in matched_student_ids]
|
||||
if leftover:
|
||||
fallback_users = Users.search([
|
||||
('id', 'in', leftover),
|
||||
('share', '=', False),
|
||||
('active', '=', True),
|
||||
])
|
||||
for u in fallback_users:
|
||||
resolved.add(u.id)
|
||||
return sorted(resolved)
|
||||
|
||||
def _create_individual_assignments(self, schedule):
|
||||
"""Create individual assignment records for each targeted student."""
|
||||
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||
@@ -233,7 +287,9 @@ class EncoachExamScheduleController(http.Controller):
|
||||
if 'batch_ids' in body:
|
||||
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
|
||||
if 'student_ids' in body:
|
||||
vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])]
|
||||
# Resolve op.student.id → res.users.id (same reason as the
|
||||
# create endpoint — see _resolve_student_user_ids docstring).
|
||||
vals['student_ids'] = [(6, 0, self._resolve_student_user_ids(body['student_ids']))]
|
||||
if 'state' in body:
|
||||
vals['state'] = body['state']
|
||||
if vals:
|
||||
|
||||
@@ -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_schedule
|
||||
from . import exam_structure
|
||||
from . import student_attempt
|
||||
from . import approval
|
||||
|
||||
@@ -39,7 +39,24 @@ class EncoachExamCustom(models.Model):
|
||||
randomize_questions = fields.Boolean(default=False)
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('pending_review', 'Pending Review'),
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('rejected', 'Rejected'),
|
||||
('published', 'Published'),
|
||||
('archived', 'Archived'),
|
||||
], default='draft', required=True)
|
||||
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')
|
||||
|
||||
# Human-in-the-loop review audit trail. Populated by the
|
||||
# /api/exam/review/<id>/(approve|reject) endpoints so we know who signed
|
||||
# off on an AI-generated exam and what their reasoning was.
|
||||
reviewed_by_id = fields.Many2one(
|
||||
'res.users', ondelete='set null',
|
||||
string='Reviewed By',
|
||||
readonly=True,
|
||||
)
|
||||
reviewed_at = fields.Datetime(string='Reviewed At', readonly=True)
|
||||
review_notes = fields.Text(
|
||||
string='Review Notes',
|
||||
help='Approver or rejecter comments. Required when rejecting back to draft.',
|
||||
)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from odoo import models, fields
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncoachQuestion(models.Model):
|
||||
@@ -67,3 +71,55 @@ class EncoachQuestion(models.Model):
|
||||
format_validated = fields.Boolean(default=False)
|
||||
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
||||
topic_id = fields.Many2one('encoach.topic', ondelete='set null')
|
||||
|
||||
ai_model_used = fields.Char(
|
||||
string='AI Model',
|
||||
help='LLM model that produced this question (e.g. gpt-4o-mini).',
|
||||
index=True,
|
||||
)
|
||||
ai_prompt_hash = fields.Char(
|
||||
string='Prompt Hash',
|
||||
help='SHA-256 hex digest of the rendered prompt used to generate this question.',
|
||||
index=True,
|
||||
)
|
||||
ai_log_id = fields.Integer(
|
||||
string='AI Generation Log ID',
|
||||
help='Row id in encoach.ai.generation.log (soft ref — no FK to avoid cross-module coupling).',
|
||||
index=True,
|
||||
)
|
||||
ai_generated_at = fields.Datetime(string='AI Generated At')
|
||||
quality_score = fields.Float(
|
||||
string='Quality Score',
|
||||
help='Aggregate quality score from automated checkers (0-1).',
|
||||
)
|
||||
quality_report = fields.Text(
|
||||
string='Quality Report (JSON)',
|
||||
help='JSON dump of the last QualityChecker + IeltsValidator run.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _auto_init(self):
|
||||
res = super()._auto_init()
|
||||
cr = self.env.cr
|
||||
for name, ddl in (
|
||||
(
|
||||
'encoach_question_skill_status_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_question_skill_status_idx "
|
||||
"ON encoach_question (skill, status)",
|
||||
),
|
||||
(
|
||||
'encoach_question_subject_difficulty_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_question_subject_difficulty_idx "
|
||||
"ON encoach_question (subject_id, difficulty) WHERE subject_id IS NOT NULL",
|
||||
),
|
||||
(
|
||||
'encoach_question_ai_prompt_hash_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_question_ai_prompt_hash_idx "
|
||||
"ON encoach_question (ai_prompt_hash) WHERE ai_prompt_hash IS NOT NULL",
|
||||
),
|
||||
):
|
||||
try:
|
||||
cr.execute(ddl)
|
||||
except Exception:
|
||||
_logger.warning("could not create index %s", name, exc_info=True)
|
||||
return res
|
||||
|
||||
@@ -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_schedule_user,encoach.exam.schedule.user,model_encoach_exam_schedule,base.group_user,1,1,1,1
|
||||
access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
|
||||
access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1
|
||||
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
|
||||
access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1
|
||||
access_encoach_approval_workflow_user,encoach.approval.workflow.user,model_encoach_approval_workflow,base.group_user,1,1,1,1
|
||||
access_encoach_approval_stage_user,encoach.approval.stage.user,model_encoach_approval_stage,base.group_user,1,1,1,1
|
||||
access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -20,6 +20,7 @@
|
||||
'openeducat_library',
|
||||
'encoach_core',
|
||||
'encoach_api',
|
||||
'encoach_ai',
|
||||
'encoach_taxonomy',
|
||||
'encoach_resources',
|
||||
],
|
||||
|
||||
@@ -22,6 +22,7 @@ from . import faq
|
||||
from . import stats
|
||||
from . import tickets
|
||||
from . import payments
|
||||
from . import paymob
|
||||
from . import platform_settings
|
||||
from . import training
|
||||
from . import reports
|
||||
|
||||
@@ -36,7 +36,7 @@ class AttendanceController(http.Controller):
|
||||
domain.append(('attendance_id.attendance_date', '=', kw['date']))
|
||||
recs = M.search(domain, limit=200, order='id desc')
|
||||
data = [_ser_att(r) for r in recs]
|
||||
return _json_response({'data': data})
|
||||
return _json_response({'items': data, 'data': data, 'total': len(data)})
|
||||
except Exception as e:
|
||||
_logger.exception('list_attendance')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -35,7 +35,7 @@ class GradesController(http.Controller):
|
||||
'date': str(r.create_date.date()) if r.create_date else '',
|
||||
'type': r.state or 'graded',
|
||||
})
|
||||
return _json_response({'data': data})
|
||||
return _json_response({'items': data, 'data': data, 'total': len(data)})
|
||||
except Exception as e:
|
||||
_logger.exception('list_grades')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -116,10 +116,36 @@ class PaymentRecordsController(http.Controller):
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_paymob_orders(self, **kw):
|
||||
"""Paymob integration is not yet wired — return empty list.
|
||||
This endpoint exists so the UI renders cleanly instead of calling a
|
||||
404-returning route."""
|
||||
return _json_response({
|
||||
'data': [], 'items': [], 'total': 0,
|
||||
'message': 'Paymob integration not configured.',
|
||||
})
|
||||
"""List Paymob orders for the calling user.
|
||||
|
||||
Admins see all orders; regular users see only their own. Orders are
|
||||
populated by the paymob.py controller's checkout + webhook flow.
|
||||
"""
|
||||
try:
|
||||
Order = request.env['encoach.paymob.order'].sudo()
|
||||
domain = []
|
||||
if not request.env.user.has_group('base.group_system'):
|
||||
domain.append(('user_id', '=', request.env.user.id))
|
||||
rows = Order.search(domain, order='create_date desc', limit=200)
|
||||
items = [{
|
||||
'id': r.id,
|
||||
'reference': r.reference,
|
||||
'amount': float(r.amount_cents or 0) / 100.0,
|
||||
'currency': r.currency,
|
||||
'state': r.state,
|
||||
'paid': r.state == 'paid',
|
||||
'description': r.description or '',
|
||||
'product_ref': r.product_ref or '',
|
||||
'paymob_order_id': r.paymob_order_id or '',
|
||||
'user_id': r.user_id.id if r.user_id else None,
|
||||
'user_name': r.user_id.name if r.user_id else '',
|
||||
'date': str(r.create_date) if r.create_date else '',
|
||||
'last_event_at': str(r.last_event_at) if r.last_event_at else '',
|
||||
'type': 'Paymob',
|
||||
} for r in rows]
|
||||
return _json_response({
|
||||
'data': items, 'items': items, 'total': len(items),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_paymob_orders')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
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 (
|
||||
jwt_required, _json_response, _error_response, _paginate,
|
||||
)
|
||||
from odoo.addons.encoach_api.utils.cache import get as _cache_get, put as _cache_put
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
|
||||
|
||||
# CEFR ordering + band ranges used when an attempt didn't store cefr_level.
|
||||
_CEFR_BY_BAND = [
|
||||
(9.0, 'C2'), (7.0, 'C1'), (5.5, 'B2'),
|
||||
(4.0, 'B1'), (3.0, 'A2'), (2.0, 'A1'), (0.0, 'Pre-A1'),
|
||||
]
|
||||
from odoo.addons.encoach_ai.services.cefr_mapper import (
|
||||
band_to_cefr as _cefr_code_for_band,
|
||||
normalize_cefr as _normalize_cefr,
|
||||
)
|
||||
|
||||
|
||||
def _cefr_for_band(band):
|
||||
if band is None:
|
||||
"""Return the display-form CEFR label (e.g. 'B2') for an IELTS band."""
|
||||
code = _cefr_code_for_band(band)
|
||||
if code is None:
|
||||
return None
|
||||
for threshold, label in _CEFR_BY_BAND:
|
||||
if band >= threshold:
|
||||
return label
|
||||
return 'Pre-A1'
|
||||
|
||||
|
||||
def _normalize_cefr(label):
|
||||
if not label:
|
||||
return None
|
||||
s = str(label).strip().lower().replace('-', '_')
|
||||
return {
|
||||
'pre_a1': 'Pre-A1', 'a1': 'A1', 'a2': 'A2',
|
||||
'b1': 'B1', 'b2': 'B2', 'c1': 'C1', 'c2': 'C2',
|
||||
}.get(s, label)
|
||||
return _normalize_cefr(code) or code
|
||||
|
||||
|
||||
def _duration_minutes(att):
|
||||
@@ -232,6 +221,23 @@ class ReportsController(http.Controller):
|
||||
include), ``months`` (trend horizon; default 6).
|
||||
"""
|
||||
try:
|
||||
# 30s TTL cache: the stats dashboard is pinged from multiple widgets
|
||||
# on every page view. Invalidated implicitly once the TTL expires;
|
||||
# write-heavy endpoints (exam release, attempt score commit) can
|
||||
# call ``cache.invalidate("reports.stats_corporate")`` if we ever
|
||||
# need stronger consistency.
|
||||
cache_key = (
|
||||
"reports.stats_corporate:"
|
||||
f"entity={kw.get('entity_id') or ''};"
|
||||
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
|
||||
f"thr={kw.get('threshold') or ''};"
|
||||
f"months={kw.get('months') or ''};"
|
||||
f"period={kw.get('period') or ''}"
|
||||
)
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return _json_response(cached)
|
||||
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw)
|
||||
|
||||
@@ -242,55 +248,88 @@ class ReportsController(http.Controller):
|
||||
if threshold > 0:
|
||||
domain.append(('overall_band', '>=', threshold / 10.0))
|
||||
|
||||
attempts = Att.search(domain)
|
||||
|
||||
skill_totals = {k: [0.0, 0] for k in
|
||||
('reading', 'listening', 'writing', 'speaking')}
|
||||
for att in attempts:
|
||||
for skill, field in (('reading', att.reading_band),
|
||||
('listening', att.listening_band),
|
||||
('writing', att.writing_band),
|
||||
('speaking', att.speaking_band)):
|
||||
if field is not None and field > 0:
|
||||
skill_totals[skill][0] += field
|
||||
skill_totals[skill][1] += 1
|
||||
|
||||
# --- Per-skill average via SQL aggregation ------------------
|
||||
# Previously each of the four skill averages required pulling the
|
||||
# full attempt rowset into Python and summing per record. This now
|
||||
# runs as a single ``read_group`` per skill (avg in SQL), dropping
|
||||
# the hot path from O(N) Python iterations to a constant number of
|
||||
# round-trips regardless of attempt volume.
|
||||
by_module = []
|
||||
for skill in ('Reading', 'Listening', 'Writing', 'Speaking'):
|
||||
total, n = skill_totals[skill.lower()]
|
||||
avg = round((total / n) * 10, 1) if n else 0
|
||||
by_module.append({'module': skill, 'score': avg, 'n': n})
|
||||
skill_attempts_considered = 0
|
||||
for display, fname in (
|
||||
('Reading', 'reading_band'),
|
||||
('Listening', 'listening_band'),
|
||||
('Writing', 'writing_band'),
|
||||
('Speaking', 'speaking_band'),
|
||||
):
|
||||
agg_domain = domain + [(fname, '>', 0)]
|
||||
rows = Att.read_group(
|
||||
agg_domain,
|
||||
fields=[f'{fname}:avg'],
|
||||
groupby=[],
|
||||
)
|
||||
if rows:
|
||||
row = rows[0]
|
||||
avg = row.get(fname) or 0
|
||||
n = row.get('__count') or Att.search_count(agg_domain)
|
||||
else:
|
||||
avg, n = 0, 0
|
||||
by_module.append({
|
||||
'module': display,
|
||||
'score': round(avg * 10, 1) if n else 0,
|
||||
'n': n,
|
||||
})
|
||||
skill_attempts_considered = max(skill_attempts_considered, n)
|
||||
|
||||
# --- Monthly trend via read_group by month ------------------
|
||||
try:
|
||||
months = max(1, min(24, int(kw.get('months') or 6)))
|
||||
except (TypeError, ValueError):
|
||||
months = 6
|
||||
|
||||
month_buckets = defaultdict(lambda: [0.0, 0])
|
||||
for att in attempts:
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
if not ct or att.overall_band is None:
|
||||
trend_domain = domain + [('overall_band', '!=', False)]
|
||||
month_rows = Att.read_group(
|
||||
trend_domain,
|
||||
fields=['overall_band:avg'],
|
||||
groupby=['started_at:month'],
|
||||
orderby='started_at:month asc',
|
||||
)
|
||||
month_buckets = {}
|
||||
for row in month_rows:
|
||||
label = row.get('started_at:month') or row.get('started_at_month')
|
||||
if not label:
|
||||
continue
|
||||
key = ct.strftime('%Y-%m')
|
||||
month_buckets[key][0] += att.overall_band
|
||||
month_buckets[key][1] += 1
|
||||
try:
|
||||
ref = datetime.strptime(label, '%B %Y')
|
||||
except ValueError:
|
||||
continue
|
||||
key = ref.strftime('%Y-%m')
|
||||
month_buckets[key] = (row.get('overall_band') or 0.0,
|
||||
row.get('__count') or 0)
|
||||
|
||||
trend = []
|
||||
today = datetime.now().replace(day=1)
|
||||
for i in range(months - 1, -1, -1):
|
||||
ref = (today - timedelta(days=31 * i)).replace(day=1)
|
||||
key = ref.strftime('%Y-%m')
|
||||
total, n = month_buckets.get(key, (0.0, 0))
|
||||
avg = round((total / n) * 10, 1) if n else 0
|
||||
trend.append({'month': ref.strftime('%b'), 'avg': avg,
|
||||
'period': key, 'n': n})
|
||||
avg_val, n = month_buckets.get(key, (0.0, 0))
|
||||
trend.append({
|
||||
'month': ref.strftime('%b'),
|
||||
'avg': round(avg_val * 10, 1) if n else 0,
|
||||
'period': key,
|
||||
'n': n,
|
||||
})
|
||||
|
||||
level_buckets = defaultdict(int)
|
||||
for att in attempts:
|
||||
label = _normalize_cefr(att.cefr_level) \
|
||||
or _cefr_for_band(att.overall_band)
|
||||
# --- CEFR distribution via read_group by cefr_level ---------
|
||||
dist_rows = Att.read_group(
|
||||
domain + [('cefr_level', '!=', False)],
|
||||
fields=[],
|
||||
groupby=['cefr_level'],
|
||||
)
|
||||
level_buckets = {}
|
||||
for row in dist_rows:
|
||||
label = _normalize_cefr(row.get('cefr_level'))
|
||||
if label:
|
||||
level_buckets[label] += 1
|
||||
level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0)
|
||||
palette = {
|
||||
'Pre-A1': 'hsl(0, 0%, 55%)',
|
||||
'A1': 'hsl(0, 72%, 51%)',
|
||||
@@ -310,35 +349,39 @@ class ReportsController(http.Controller):
|
||||
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
|
||||
]
|
||||
|
||||
entity_buckets = defaultdict(lambda: [0.0, 0, 'Unassigned'])
|
||||
for att in attempts:
|
||||
if att.overall_band is None:
|
||||
continue
|
||||
eid = att.entity_id.id or 0
|
||||
entity_buckets[eid][0] += att.overall_band
|
||||
entity_buckets[eid][1] += 1
|
||||
entity_buckets[eid][2] = att.entity_id.name or 'Unassigned'
|
||||
# --- Entity comparison via read_group by entity_id ----------
|
||||
entity_rows = Att.read_group(
|
||||
domain + [('overall_band', '!=', False)],
|
||||
fields=['overall_band:avg'],
|
||||
groupby=['entity_id'],
|
||||
)
|
||||
comparison = []
|
||||
for eid, (total, n, name) in entity_buckets.items():
|
||||
for row in entity_rows:
|
||||
entity = row.get('entity_id') or (None, 'Unassigned')
|
||||
eid, ename = (entity[0], entity[1]) if isinstance(entity, (list, tuple)) else (None, 'Unassigned')
|
||||
avg_val = row.get('overall_band') or 0
|
||||
comparison.append({
|
||||
'entity_id': eid or None,
|
||||
'entity_name': name,
|
||||
'avg': round((total / n) * 10, 1) if n else 0,
|
||||
'attempts': n,
|
||||
'entity_id': eid,
|
||||
'entity_name': ename or 'Unassigned',
|
||||
'avg': round(avg_val * 10, 1),
|
||||
'attempts': row.get('__count') or 0,
|
||||
})
|
||||
comparison.sort(key=lambda r: -r['avg'])
|
||||
|
||||
return _json_response({
|
||||
total_considered = Att.search_count(domain)
|
||||
payload = {
|
||||
'by_module': by_module,
|
||||
'trend': trend,
|
||||
'distribution': distribution,
|
||||
'comparison': comparison,
|
||||
'meta': {
|
||||
'attempts_considered': len(attempts),
|
||||
'attempts_considered': total_considered,
|
||||
'threshold': threshold,
|
||||
'months': months,
|
||||
},
|
||||
})
|
||||
}
|
||||
_cache_put(cache_key, payload, ttl_seconds=30)
|
||||
return _json_response(payload)
|
||||
except Exception as e:
|
||||
_logger.exception('stats-corporate report failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -37,7 +37,7 @@ class TimetableController(http.Controller):
|
||||
domain.append(('batch_id', '=', int(kw['batch_id'])))
|
||||
recs = M.search(domain, limit=200, order='id desc')
|
||||
data = [_ser_session(r) for r in recs]
|
||||
return _json_response({'data': data})
|
||||
return _json_response({'items': data, 'data': data, 'total': len(data)})
|
||||
except Exception as e:
|
||||
_logger.exception('list_timetable')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -192,7 +192,7 @@ class UsersRolesController(http.Controller):
|
||||
total = M.search_count(domain)
|
||||
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
items = [_ser_role(r) for r in recs]
|
||||
return _json_response({'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -299,7 +299,7 @@ class UsersRolesController(http.Controller):
|
||||
domain.append(('code', 'ilike', kw['search']))
|
||||
recs = M.search(domain, limit=500, order='topic, code')
|
||||
items = [_ser_perm(r) for r in recs]
|
||||
return _json_response({'data': items, 'total': len(items)})
|
||||
return _json_response({'items': items, 'data': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -417,7 +417,7 @@ class UsersRolesController(http.Controller):
|
||||
'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids],
|
||||
'effective_permission_count': len(all_perms),
|
||||
})
|
||||
return _json_response({'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
except Exception as e:
|
||||
_logger.exception('list_users_with_roles')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -9,3 +9,4 @@ from . import course_ext
|
||||
from . import asset
|
||||
from . import ticket
|
||||
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):
|
||||
@@ -35,3 +39,129 @@ class EncoachTicket(models.Model):
|
||||
entity_id = fields.Many2one('encoach.entity', string='Entity')
|
||||
|
||||
resolved_at = fields.Datetime('Resolved At')
|
||||
|
||||
STATUS_LABELS = {
|
||||
'open': 'Open',
|
||||
'in_progress': 'In Progress',
|
||||
'resolved': 'Resolved',
|
||||
'closed': 'Closed',
|
||||
}
|
||||
|
||||
def _notify_ticket_event(self, event, *, old_status=None, new_status=None,
|
||||
old_assignee=None, new_assignee=None):
|
||||
"""Post a chatter message and push a bus event for status/assignee changes.
|
||||
|
||||
Uses :class:`bus.bus` when available so both the reporter and the new
|
||||
assignee see near real-time updates in the frontend, and always falls
|
||||
back to ``mail.thread`` chatter so the change is auditable even when
|
||||
the bus is unavailable (e.g. during cron jobs or tests).
|
||||
"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
if event == 'status':
|
||||
body = _("Status: %s → %s") % (
|
||||
self.STATUS_LABELS.get(old_status, old_status or '—'),
|
||||
self.STATUS_LABELS.get(new_status, new_status or '—'),
|
||||
)
|
||||
elif event == 'assignee':
|
||||
old_name = old_assignee.display_name if old_assignee else _('Unassigned')
|
||||
new_name = new_assignee.display_name if new_assignee else _('Unassigned')
|
||||
body = _("Assignee: %s → %s") % (old_name, new_name)
|
||||
else:
|
||||
return
|
||||
self.message_post(body=body, subtype_xmlid='mail.mt_note')
|
||||
except Exception:
|
||||
_logger.exception("ticket %s: chatter notify failed", self.id)
|
||||
|
||||
try:
|
||||
bus = self.env['bus.bus'].sudo()
|
||||
recipients = set()
|
||||
if self.reporter_id:
|
||||
recipients.add(('res.partner', self.reporter_id.partner_id.id))
|
||||
for user in (old_assignee, new_assignee, self.assignee_id):
|
||||
if user and user.partner_id:
|
||||
recipients.add(('res.partner', user.partner_id.id))
|
||||
payload = {
|
||||
'type': 'encoach.ticket',
|
||||
'event': event,
|
||||
'ticket_id': self.id,
|
||||
'subject': self.subject,
|
||||
'status': self.status,
|
||||
'assignee_id': self.assignee_id.id or False,
|
||||
'old_status': old_status,
|
||||
'new_status': new_status,
|
||||
}
|
||||
for channel in recipients:
|
||||
bus._sendone(channel, 'encoach.ticket/notify', payload)
|
||||
except Exception:
|
||||
_logger.debug("ticket %s: bus push skipped", self.id, exc_info=True)
|
||||
|
||||
def write(self, vals):
|
||||
tracked = {}
|
||||
if vals.get('status') or vals.get('assignee_id') is not None:
|
||||
for rec in self:
|
||||
tracked[rec.id] = {
|
||||
'status': rec.status,
|
||||
'assignee': rec.assignee_id,
|
||||
}
|
||||
res = super().write(vals)
|
||||
if not tracked:
|
||||
return res
|
||||
|
||||
now = fields.Datetime.now()
|
||||
for rec in self:
|
||||
before = tracked.get(rec.id) or {}
|
||||
old_status = before.get('status')
|
||||
old_assignee = before.get('assignee')
|
||||
if 'status' in vals and old_status != rec.status:
|
||||
if rec.status in ('resolved', 'closed') and not rec.resolved_at:
|
||||
rec.resolved_at = now
|
||||
rec._notify_ticket_event(
|
||||
'status', old_status=old_status, new_status=rec.status,
|
||||
)
|
||||
if 'assignee_id' in vals and (old_assignee or rec.assignee_id) and \
|
||||
(old_assignee.id if old_assignee else False) != (rec.assignee_id.id or False):
|
||||
rec._notify_ticket_event(
|
||||
'assignee', old_assignee=old_assignee, new_assignee=rec.assignee_id,
|
||||
)
|
||||
return res
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
try:
|
||||
rec.message_post(
|
||||
body=_("Ticket opened by %s") % rec.reporter_id.display_name,
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
except Exception:
|
||||
_logger.debug("ticket %s: initial chatter failed", rec.id, exc_info=True)
|
||||
return records
|
||||
|
||||
@api.model
|
||||
def _auto_init(self):
|
||||
res = super()._auto_init()
|
||||
cr = self.env.cr
|
||||
for name, ddl in (
|
||||
(
|
||||
'encoach_ticket_entity_status_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_ticket_entity_status_idx "
|
||||
"ON encoach_ticket (entity_id, status) WHERE entity_id IS NOT NULL",
|
||||
),
|
||||
(
|
||||
'encoach_ticket_reporter_status_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_ticket_reporter_status_idx "
|
||||
"ON encoach_ticket (reporter_id, status)",
|
||||
),
|
||||
(
|
||||
'encoach_ticket_assignee_status_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_ticket_assignee_status_idx "
|
||||
"ON encoach_ticket (assignee_id, status) WHERE assignee_id IS NOT NULL",
|
||||
),
|
||||
):
|
||||
try:
|
||||
cr.execute(ddl)
|
||||
except Exception:
|
||||
_logger.warning("could not create index %s", name, exc_info=True)
|
||||
return res
|
||||
|
||||
@@ -23,3 +23,5 @@ access_encoach_vocab_item_all,encoach.vocab.item.all,model_encoach_vocab_item,ba
|
||||
access_encoach_vocab_progress_all,encoach.vocab.progress.all,model_encoach_vocab_progress,base.group_user,1,1,1,1
|
||||
access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_rule,base.group_user,1,1,1,1
|
||||
access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1
|
||||
access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0
|
||||
access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_exam_template'],
|
||||
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_ai'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/cat_session_views.xml',
|
||||
|
||||
@@ -8,6 +8,7 @@ from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
||||
)
|
||||
from odoo.addons.encoach_ai.services.cefr_mapper import theta_to_cefr as _theta_to_cefr
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,18 +16,6 @@ LEARNING_RATE = 0.3
|
||||
SEM_THRESHOLD = 0.3
|
||||
MAX_QUESTIONS = 40
|
||||
|
||||
THETA_CEFR = [
|
||||
(-3.0, 'pre_a1'), (-2.0, 'a1'), (-1.0, 'a2'),
|
||||
(0.0, 'b1'), (1.0, 'b2'), (2.0, 'c1'), (3.0, 'c2'),
|
||||
]
|
||||
|
||||
|
||||
def _theta_to_cefr(theta):
|
||||
for boundary, level in THETA_CEFR:
|
||||
if theta <= boundary:
|
||||
return level
|
||||
return 'c2'
|
||||
|
||||
|
||||
def _irt_probability(theta, a, b, c):
|
||||
"""3PL IRT probability."""
|
||||
|
||||
@@ -1,83 +1,20 @@
|
||||
class CefrMapper:
|
||||
"""Maps IRT theta values to CEFR levels and IELTS band scores."""
|
||||
"""Thin re-export of the canonical CEFR mapper for backward compat.
|
||||
|
||||
THETA_TO_CEFR = [
|
||||
(-4.0, -2.5, 'pre_a1'),
|
||||
(-2.5, -1.5, 'a1'),
|
||||
(-1.5, -0.5, 'a2'),
|
||||
(-0.5, 0.5, 'b1'),
|
||||
(0.5, 1.5, 'b2'),
|
||||
(1.5, 2.5, 'c1'),
|
||||
(2.5, 4.0, 'c2'),
|
||||
]
|
||||
The canonical implementation lives in ``encoach_ai.services.cefr_mapper``.
|
||||
This shim is kept so existing imports like
|
||||
``from odoo.addons.encoach_placement.services.cefr_mapper import CefrMapper``
|
||||
continue to work. See P0.9 in §21 of docs/PROJECT_SUMMARY.md.
|
||||
"""
|
||||
|
||||
CEFR_TO_BAND = {
|
||||
'pre_a1': 2.0,
|
||||
'a1': 3.0,
|
||||
'a2': 4.0,
|
||||
'b1': 5.0,
|
||||
'b2': 6.5,
|
||||
'c1': 7.5,
|
||||
'c2': 9.0,
|
||||
}
|
||||
|
||||
CEFR_LABELS = {
|
||||
'pre_a1': 'Pre-A1 (Beginner)',
|
||||
'a1': 'A1 (Elementary)',
|
||||
'a2': 'A2 (Pre-Intermediate)',
|
||||
'b1': 'B1 (Intermediate)',
|
||||
'b2': 'B2 (Upper-Intermediate)',
|
||||
'c1': 'C1 (Advanced)',
|
||||
'c2': 'C2 (Proficient)',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def theta_to_cefr(theta):
|
||||
for low, high, level in CefrMapper.THETA_TO_CEFR:
|
||||
if low <= theta < high:
|
||||
return level
|
||||
return 'c2' if theta >= 2.5 else 'pre_a1'
|
||||
|
||||
@staticmethod
|
||||
def theta_to_band(theta):
|
||||
cefr = CefrMapper.theta_to_cefr(theta)
|
||||
base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0)
|
||||
|
||||
for low, high, level in CefrMapper.THETA_TO_CEFR:
|
||||
if level == cefr:
|
||||
range_width = high - low
|
||||
if range_width > 0:
|
||||
position = (theta - low) / range_width
|
||||
else:
|
||||
position = 0.5
|
||||
|
||||
cefr_list = list(CefrMapper.CEFR_TO_BAND.keys())
|
||||
idx = cefr_list.index(cefr)
|
||||
next_band = CefrMapper.CEFR_TO_BAND.get(
|
||||
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
|
||||
)
|
||||
|
||||
band = base_band + position * (next_band - base_band)
|
||||
return round(band * 2) / 2
|
||||
|
||||
return base_band
|
||||
|
||||
@staticmethod
|
||||
def band_to_cefr(band):
|
||||
if band < 2.5:
|
||||
return 'pre_a1'
|
||||
if band < 3.5:
|
||||
return 'a1'
|
||||
if band < 4.5:
|
||||
return 'a2'
|
||||
if band < 5.5:
|
||||
return 'b1'
|
||||
if band < 7.0:
|
||||
return 'b2'
|
||||
if band < 8.0:
|
||||
return 'c1'
|
||||
return 'c2'
|
||||
|
||||
@staticmethod
|
||||
def get_cefr_label(cefr_code):
|
||||
return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code)
|
||||
from odoo.addons.encoach_ai.services.cefr_mapper import ( # noqa: F401
|
||||
CefrMapper,
|
||||
band_to_cefr,
|
||||
theta_to_cefr,
|
||||
theta_to_band,
|
||||
cefr_to_band,
|
||||
normalize_cefr,
|
||||
cefr_label,
|
||||
THETA_TO_CEFR,
|
||||
CEFR_TO_BAND,
|
||||
CEFR_LABELS,
|
||||
)
|
||||
|
||||
@@ -21,11 +21,7 @@ BAND_TO_CEFR = [
|
||||
]
|
||||
|
||||
|
||||
def _band_to_cefr(band):
|
||||
for threshold, level in BAND_TO_CEFR:
|
||||
if band >= threshold:
|
||||
return level
|
||||
return 'pre_a1'
|
||||
from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402
|
||||
|
||||
|
||||
def _question_to_student_dict(q):
|
||||
|
||||
@@ -16,11 +16,7 @@ BAND_TO_CEFR = [
|
||||
]
|
||||
|
||||
|
||||
def _band_to_cefr(band):
|
||||
for threshold, level in BAND_TO_CEFR:
|
||||
if band >= threshold:
|
||||
return level
|
||||
return 'pre_a1'
|
||||
from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402
|
||||
|
||||
|
||||
def _recompute_bands(attempt):
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from odoo import models, fields
|
||||
import logging
|
||||
|
||||
from odoo import api, models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncoachStudentAttempt(models.Model):
|
||||
@@ -39,3 +43,31 @@ class EncoachStudentAttempt(models.Model):
|
||||
score_ids = fields.One2many('encoach.score', 'attempt_id')
|
||||
autosave_data = fields.Text()
|
||||
feedback_ids = fields.One2many('encoach.feedback', 'attempt_id')
|
||||
|
||||
@api.model
|
||||
def _auto_init(self):
|
||||
res = super()._auto_init()
|
||||
cr = self.env.cr
|
||||
for name, ddl in (
|
||||
(
|
||||
'encoach_student_attempt_student_status_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_student_attempt_student_status_idx "
|
||||
"ON encoach_student_attempt (student_id, status)",
|
||||
),
|
||||
(
|
||||
'encoach_student_attempt_exam_status_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_student_attempt_exam_status_idx "
|
||||
"ON encoach_student_attempt (exam_id, status) WHERE exam_id IS NOT NULL",
|
||||
),
|
||||
(
|
||||
'encoach_student_attempt_entity_released_idx',
|
||||
"CREATE INDEX IF NOT EXISTS encoach_student_attempt_entity_released_idx "
|
||||
"ON encoach_student_attempt (entity_id, released_at) "
|
||||
"WHERE entity_id IS NOT NULL AND released_at IS NOT NULL",
|
||||
),
|
||||
):
|
||||
try:
|
||||
cr.execute(ddl)
|
||||
except Exception:
|
||||
_logger.warning("could not create index %s", name, exc_info=True)
|
||||
return res
|
||||
|
||||
@@ -102,7 +102,8 @@ class EncoachTaxonomyController(http.Controller):
|
||||
try:
|
||||
recs = request.env['encoach.subject'].sudo().search([])
|
||||
items = [_subject_dict(s) for s in recs]
|
||||
return _json_response({'data': items, 'subjects': items})
|
||||
return _json_response({'items': items, 'data': items,
|
||||
'subjects': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
_logger.exception('list_subjects')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -205,7 +206,9 @@ class EncoachTaxonomyController(http.Controller):
|
||||
if kw.get('subject_id'):
|
||||
domain_filter.append(('subject_id', '=', int(kw['subject_id'])))
|
||||
recs = request.env['encoach.domain'].sudo().search(domain_filter)
|
||||
return _json_response({'data': [_domain_dict(d) for d in recs]})
|
||||
items = [_domain_dict(d) for d in recs]
|
||||
return _json_response({'items': items, 'data': items,
|
||||
'total': len(items)})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -290,7 +293,9 @@ class EncoachTaxonomyController(http.Controller):
|
||||
domains = request.env['encoach.domain'].sudo().search([('subject_id', '=', int(kw['subject_id']))])
|
||||
domain_filter.append(('domain_id', 'in', domains.ids))
|
||||
recs = request.env['encoach.topic'].sudo().search(domain_filter)
|
||||
return _json_response({'data': [_topic_dict(t) for t in recs]})
|
||||
items = [_topic_dict(t) for t in recs]
|
||||
return _json_response({'items': items, 'data': items,
|
||||
'total': len(items)})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
|
||||
@@ -28,9 +28,17 @@ class EncoachEmbedding(models.Model):
|
||||
content_text = fields.Text()
|
||||
metadata_json = fields.Text(default='{}')
|
||||
|
||||
course_id = fields.Integer(string='Course ID', index=True)
|
||||
subject_id = fields.Integer(string='Subject ID', index=True)
|
||||
entity_id = fields.Integer(string='Entity ID', index=True)
|
||||
taxonomy = fields.Char(string='Taxonomy', index=True)
|
||||
content_hash = fields.Char(string='Content SHA256', index=True)
|
||||
chunk_index = fields.Integer(string='Chunk #', default=0)
|
||||
chunk_total = fields.Integer(string='Total Chunks', default=1)
|
||||
|
||||
_content_unique = models.Constraint(
|
||||
'UNIQUE(content_type, content_id)',
|
||||
'Each content item can only have one embedding.',
|
||||
'UNIQUE(content_type, content_id, chunk_index)',
|
||||
'Each content item chunk can only have one embedding.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
@@ -82,27 +90,46 @@ class EncoachEmbedding(models.Model):
|
||||
return index_all(self.env)
|
||||
|
||||
@api.model
|
||||
def similarity_search(self, query_vector, *, content_type=None, limit=10):
|
||||
"""Find similar embeddings using cosine distance."""
|
||||
def similarity_search(self, query_vector, *, content_type=None, limit=10,
|
||||
course_id=None, subject_id=None, entity_id=None,
|
||||
taxonomy=None):
|
||||
"""Find similar embeddings using cosine distance.
|
||||
|
||||
Optional metadata filters (course/subject/entity/taxonomy) are applied
|
||||
as SQL equality predicates to narrow the candidate set before ranking.
|
||||
"""
|
||||
vec_str = '[' + ','.join(str(v) for v in query_vector) + ']'
|
||||
where = "WHERE embedding IS NOT NULL"
|
||||
params = [vec_str, limit]
|
||||
conditions = ["embedding IS NOT NULL"]
|
||||
extra_params = []
|
||||
if content_type:
|
||||
where += " AND content_type = %s"
|
||||
params = [vec_str, content_type, limit]
|
||||
conditions.append("content_type = %s")
|
||||
extra_params.append(content_type)
|
||||
if course_id:
|
||||
conditions.append("course_id = %s")
|
||||
extra_params.append(int(course_id))
|
||||
if subject_id:
|
||||
conditions.append("subject_id = %s")
|
||||
extra_params.append(int(subject_id))
|
||||
if entity_id:
|
||||
conditions.append("entity_id = %s")
|
||||
extra_params.append(int(entity_id))
|
||||
if taxonomy:
|
||||
conditions.append("taxonomy = %s")
|
||||
extra_params.append(taxonomy)
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT id, content_type, content_id, content_text, metadata_json,
|
||||
course_id, subject_id, entity_id, taxonomy,
|
||||
chunk_index, chunk_total,
|
||||
1 - (embedding <=> %s::vector) AS similarity
|
||||
FROM encoach_embedding
|
||||
{where}
|
||||
ORDER BY embedding <=> %s::vector
|
||||
LIMIT %s
|
||||
"""
|
||||
if content_type:
|
||||
self.env.cr.execute(query, (vec_str, content_type, vec_str, limit))
|
||||
else:
|
||||
self.env.cr.execute(query, (vec_str, vec_str, limit))
|
||||
params = [vec_str] + extra_params + [vec_str, limit]
|
||||
self.env.cr.execute(query, params)
|
||||
|
||||
results = []
|
||||
for row in self.env.cr.dictfetchall():
|
||||
@@ -117,6 +144,12 @@ class EncoachEmbedding(models.Model):
|
||||
'content_id': row['content_id'],
|
||||
'text': row['content_text'],
|
||||
'metadata': metadata,
|
||||
'course_id': row['course_id'] or None,
|
||||
'subject_id': row['subject_id'] or None,
|
||||
'entity_id': row['entity_id'] or None,
|
||||
'taxonomy': row['taxonomy'] or None,
|
||||
'chunk_index': row['chunk_index'],
|
||||
'chunk_total': row['chunk_total'],
|
||||
'similarity': round(row['similarity'], 4),
|
||||
})
|
||||
return results
|
||||
|
||||
@@ -1,13 +1,81 @@
|
||||
"""Embedding service — encode text and manage vector storage."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_model_instance = None
|
||||
|
||||
CHUNK_CHAR_LIMIT = 2000
|
||||
CHUNK_OVERLAP = 200
|
||||
_PARA_SPLIT = re.compile(r"\n\s*\n+")
|
||||
_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+")
|
||||
|
||||
|
||||
def _chunk_text(text, *, limit=CHUNK_CHAR_LIMIT, overlap=CHUNK_OVERLAP):
|
||||
"""Split long text into semantically-aware chunks of at most ``limit`` chars.
|
||||
|
||||
Preserves paragraph and sentence boundaries where possible. Each chunk
|
||||
after the first retains ``overlap`` chars of tail context for continuity.
|
||||
Short inputs return a single-chunk list unchanged.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
buf = ""
|
||||
for para in _PARA_SPLIT.split(text):
|
||||
para = para.strip()
|
||||
if not para:
|
||||
continue
|
||||
candidate = (buf + "\n\n" + para) if buf else para
|
||||
if len(candidate) <= limit:
|
||||
buf = candidate
|
||||
continue
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
buf = ""
|
||||
if len(para) <= limit:
|
||||
buf = para
|
||||
continue
|
||||
for sent in _SENT_SPLIT.split(para):
|
||||
sent = sent.strip()
|
||||
if not sent:
|
||||
continue
|
||||
cand = (buf + " " + sent) if buf else sent
|
||||
if len(cand) <= limit:
|
||||
buf = cand
|
||||
continue
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
if len(sent) <= limit:
|
||||
buf = sent
|
||||
else:
|
||||
for i in range(0, len(sent), limit - overlap):
|
||||
chunks.append(sent[i:i + limit])
|
||||
buf = ""
|
||||
if buf:
|
||||
chunks.append(buf)
|
||||
|
||||
if overlap > 0 and len(chunks) > 1:
|
||||
with_overlap = [chunks[0]]
|
||||
for i in range(1, len(chunks)):
|
||||
tail = chunks[i - 1][-overlap:]
|
||||
with_overlap.append((tail + " " + chunks[i]).strip())
|
||||
chunks = with_overlap
|
||||
return chunks
|
||||
|
||||
|
||||
def _sha256(text):
|
||||
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _get_model():
|
||||
"""Lazy-load the sentence-transformers model (cached across calls)."""
|
||||
@@ -47,42 +115,77 @@ class EmbeddingService:
|
||||
return [e.tolist() for e in embeddings]
|
||||
|
||||
def upsert(self, content_type, content_id, text, metadata=None):
|
||||
"""Encode and store (or update) a single embedding.
|
||||
"""Encode and store (or update) one embedding per text chunk.
|
||||
|
||||
Long ``text`` is split by :func:`_chunk_text` into chunks of at most
|
||||
``CHUNK_CHAR_LIMIT`` chars. Each chunk is stored as a separate row
|
||||
keyed by ``(content_type, content_id, chunk_index)``. Dedicated
|
||||
metadata columns (course_id, subject_id, entity_id, taxonomy,
|
||||
content_hash) are populated from ``metadata`` when present.
|
||||
|
||||
Returns:
|
||||
encoach.embedding record
|
||||
list of encoach.embedding records (one per chunk). Empty list
|
||||
if input text is blank.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
return []
|
||||
|
||||
existing = self.Embedding.search([
|
||||
metadata = dict(metadata or {})
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
return []
|
||||
|
||||
vectors = self.encode(chunks)
|
||||
existing_all = self.Embedding.search([
|
||||
('content_type', '=', content_type),
|
||||
('content_id', '=', content_id),
|
||||
], limit=1)
|
||||
])
|
||||
by_idx = {rec.chunk_index: rec for rec in existing_all}
|
||||
stale = [rec for rec in existing_all if rec.chunk_index >= len(chunks)]
|
||||
if stale:
|
||||
self.Embedding.browse([r.id for r in stale]).unlink()
|
||||
|
||||
vectors = self.encode([text])
|
||||
meta_str = json.dumps(metadata or {})
|
||||
records = []
|
||||
total = len(chunks)
|
||||
for idx, (chunk, vec) in enumerate(zip(chunks, vectors)):
|
||||
chunk_hash = _sha256(chunk)
|
||||
base_vals = {
|
||||
'content_text': chunk[:10000],
|
||||
'metadata_json': json.dumps(metadata),
|
||||
'course_id': int(metadata.get('course_id') or 0) or False,
|
||||
'subject_id': int(metadata.get('subject_id') or 0) or False,
|
||||
'entity_id': int(metadata.get('entity_id') or 0) or False,
|
||||
'taxonomy': metadata.get('taxonomy') or False,
|
||||
'content_hash': chunk_hash,
|
||||
'chunk_index': idx,
|
||||
'chunk_total': total,
|
||||
}
|
||||
rec = by_idx.get(idx)
|
||||
if rec:
|
||||
if rec.content_hash != chunk_hash or rec.chunk_total != total:
|
||||
rec.write(base_vals)
|
||||
rec.set_embedding(vec)
|
||||
else:
|
||||
rec.write({k: v for k, v in base_vals.items()
|
||||
if k in ('metadata_json', 'chunk_total')})
|
||||
else:
|
||||
rec = self.Embedding.create({
|
||||
'content_type': content_type,
|
||||
'content_id': content_id,
|
||||
**base_vals,
|
||||
})
|
||||
rec.set_embedding(vec)
|
||||
records.append(rec)
|
||||
return records
|
||||
|
||||
if existing:
|
||||
existing.write({
|
||||
'content_text': text[:10000],
|
||||
'metadata_json': meta_str,
|
||||
})
|
||||
existing.set_embedding(vectors[0])
|
||||
return existing
|
||||
|
||||
record = self.Embedding.create({
|
||||
'content_type': content_type,
|
||||
'content_id': content_id,
|
||||
'content_text': text[:10000],
|
||||
'metadata_json': meta_str,
|
||||
})
|
||||
record.set_embedding(vectors[0])
|
||||
return record
|
||||
|
||||
def search(self, query, *, content_type=None, limit=10):
|
||||
def search(self, query, *, content_type=None, limit=10,
|
||||
course_id=None, subject_id=None, entity_id=None,
|
||||
taxonomy=None):
|
||||
"""Semantic search — encode query and find similar content.
|
||||
|
||||
Optional metadata filters narrow the candidate pool to a specific
|
||||
course / subject / entity / taxonomy before ranking.
|
||||
|
||||
Returns:
|
||||
list of dicts with text, metadata, similarity score
|
||||
"""
|
||||
@@ -95,6 +198,10 @@ class EmbeddingService:
|
||||
vectors[0],
|
||||
content_type=content_type,
|
||||
limit=limit,
|
||||
course_id=course_id,
|
||||
subject_id=subject_id,
|
||||
entity_id=entity_id,
|
||||
taxonomy=taxonomy,
|
||||
)
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
_logger.info("Vector search for '%s' returned %d results in %dms",
|
||||
|
||||
@@ -11,6 +11,8 @@ MODEL_CONFIG = [
|
||||
'text_field': 'name',
|
||||
'description_field': 'description',
|
||||
'metadata_fields': [],
|
||||
'course_field': 'id',
|
||||
'subject_field': 'subject_id',
|
||||
},
|
||||
{
|
||||
'model': 'encoach.resource',
|
||||
@@ -18,13 +20,19 @@ MODEL_CONFIG = [
|
||||
'text_field': 'name',
|
||||
'description_field': 'content',
|
||||
'metadata_fields': ['type', 'cefr_level', 'difficulty'],
|
||||
'course_field': 'course_id',
|
||||
'subject_field': 'subject_id',
|
||||
'entity_field': 'entity_id',
|
||||
'taxonomy_field': 'type',
|
||||
},
|
||||
{
|
||||
'model': 'encoach.question',
|
||||
'content_type': 'question',
|
||||
'text_field': 'name',
|
||||
'text_field': 'stem',
|
||||
'description_field': None,
|
||||
'metadata_fields': ['question_type', 'difficulty', 'skill'],
|
||||
'subject_field': 'subject_id',
|
||||
'taxonomy_field': 'skill',
|
||||
},
|
||||
{
|
||||
'model': 'encoach.course.module',
|
||||
@@ -32,6 +40,8 @@ MODEL_CONFIG = [
|
||||
'text_field': 'name',
|
||||
'description_field': 'description',
|
||||
'metadata_fields': ['skill'],
|
||||
'course_field': 'course_id',
|
||||
'taxonomy_field': 'skill',
|
||||
},
|
||||
{
|
||||
'model': 'encoach.ai.generation.log',
|
||||
@@ -39,6 +49,7 @@ MODEL_CONFIG = [
|
||||
'text_field': 'brief',
|
||||
'description_field': 'generated_content',
|
||||
'metadata_fields': ['course_type', 'status'],
|
||||
'taxonomy_field': 'course_type',
|
||||
},
|
||||
{
|
||||
'model': 'encoach.chapter.material',
|
||||
@@ -46,10 +57,21 @@ MODEL_CONFIG = [
|
||||
'text_field': 'name',
|
||||
'description_field': 'description',
|
||||
'metadata_fields': ['type'],
|
||||
'course_field': 'course_id',
|
||||
'taxonomy_field': 'type',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _safe_id(record, field):
|
||||
if not field or not hasattr(record, field):
|
||||
return None
|
||||
val = getattr(record, field)
|
||||
if hasattr(val, 'id'):
|
||||
return val.id or None
|
||||
return int(val) if val else None
|
||||
|
||||
|
||||
def _get_text(record, config):
|
||||
"""Extract indexable text from a record."""
|
||||
parts = []
|
||||
@@ -69,13 +91,32 @@ def _get_text(record, config):
|
||||
|
||||
|
||||
def _get_metadata(record, config):
|
||||
"""Extract metadata dict from a record."""
|
||||
"""Extract metadata dict from a record, including structured
|
||||
course/subject/entity/taxonomy fields used by the RAG retriever."""
|
||||
meta = {}
|
||||
for f in config.get('metadata_fields', []):
|
||||
if hasattr(record, f):
|
||||
val = getattr(record, f)
|
||||
if val:
|
||||
meta[f] = str(val) if not isinstance(val, (int, float, bool)) else val
|
||||
|
||||
course_id = _safe_id(record, config.get('course_field'))
|
||||
if course_id:
|
||||
meta['course_id'] = course_id
|
||||
subject_id = _safe_id(record, config.get('subject_field'))
|
||||
if subject_id:
|
||||
meta['subject_id'] = subject_id
|
||||
entity_id = _safe_id(record, config.get('entity_field'))
|
||||
if entity_id:
|
||||
meta['entity_id'] = entity_id
|
||||
|
||||
taxonomy_field = config.get('taxonomy_field')
|
||||
if taxonomy_field and hasattr(record, taxonomy_field):
|
||||
tx = getattr(record, taxonomy_field)
|
||||
if tx:
|
||||
meta['taxonomy'] = (
|
||||
str(tx.display_name) if hasattr(tx, 'display_name') else str(tx)
|
||||
)
|
||||
return meta
|
||||
|
||||
|
||||
|
||||
24
backend/openeducat_erp-19.0/openeducat_erp-19.0/.coveragerc
Normal file
24
backend/openeducat_erp-19.0/openeducat_erp-19.0/.coveragerc
Normal file
@@ -0,0 +1,24 @@
|
||||
# Config file .coveragerc
|
||||
# adapt the include for your project
|
||||
|
||||
[report]
|
||||
include =
|
||||
*/openeducat/openeducat_erp/*
|
||||
|
||||
omit =
|
||||
*/tests/*
|
||||
*__init__.py
|
||||
|
||||
# Regexes for lines to exclude from consideration
|
||||
exclude_lines =
|
||||
# Have to re-enable the standard pragma
|
||||
pragma: no cover
|
||||
|
||||
# Don't complain about null context checking
|
||||
if context is None:
|
||||
|
||||
# Don't complain about odoo basic imports checking
|
||||
from odoo import models, fields, api
|
||||
|
||||
# Don't complain about odoo basic imports checking
|
||||
from odoo*
|
||||
6
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitattributes
vendored
Normal file
6
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Normalise line endings:
|
||||
* text=auto
|
||||
|
||||
# Prevent certain files from being exported:
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
38
backend/openeducat_erp-19.0/openeducat_erp-19.0/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
backend/openeducat_erp-19.0/openeducat_erp-19.0/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
36
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitignore
vendored
Normal file
36
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
*.py[cod]
|
||||
*.settings
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Packages
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
eggs
|
||||
parts
|
||||
bin
|
||||
var
|
||||
sdist
|
||||
develop-eggs
|
||||
.installed.cfg
|
||||
lib
|
||||
lib64
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.tox
|
||||
nosetests.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
# Mr Developer
|
||||
.mr.developer.cfg
|
||||
.project
|
||||
.pydevproject
|
||||
.idea
|
||||
@@ -0,0 +1,149 @@
|
||||
exclude: |
|
||||
(?x)
|
||||
# NOT INSTALLABLE ADDONS
|
||||
# END NOT INSTALLABLE ADDONS
|
||||
# Files and folders generated by bots, to avoid loops
|
||||
^setup/|/static/description/index\.html$|
|
||||
# We don't want to mess with tool-generated files
|
||||
.svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|
|
||||
# Maybe reactivate this when all README files include prettier ignore tags?
|
||||
^README\.md$|
|
||||
# Library files can have extraneous formatting (even minimized)
|
||||
/static/(src/)?lib/|
|
||||
# Repos using Sphinx to generate docs don't need prettying
|
||||
^docs/_templates/.*\.html$|
|
||||
# You don't usually want a bot to modify your legal texts
|
||||
(LICENSE.*|COPYING.*)
|
||||
default_language_version:
|
||||
python: python3
|
||||
node: "16.17.0"
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
# These files are most likely copier diff rejection junks; if found,
|
||||
# review them manually, fix the problem (if needed) and remove them
|
||||
- id: forbidden-files
|
||||
name: forbidden files
|
||||
entry: found forbidden files; remove them
|
||||
language: fail
|
||||
files: "\\.rej$"
|
||||
- id: en-po-files
|
||||
name: en.po files cannot exist
|
||||
entry: found a en.po file
|
||||
language: fail
|
||||
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
|
||||
- repo: https://github.com/oca/maintainer-tools
|
||||
rev: 4cd2b852214dead80822e93e6749b16f2785b2fe
|
||||
hooks:
|
||||
# update the NOT INSTALLABLE ADDONS section above
|
||||
- id: oca-update-pre-commit-excluded-addons
|
||||
# - id: oca-fix-manifest-website
|
||||
# args: ["https://github.com/OCA/website"]
|
||||
- repo: https://github.com/myint/autoflake
|
||||
rev: v1.6.1
|
||||
hooks:
|
||||
- id: autoflake
|
||||
args:
|
||||
- --expand-star-imports
|
||||
- --ignore-init-module-imports
|
||||
- --in-place
|
||||
#- --remove-unused-variables
|
||||
- --remove-all-unused-imports
|
||||
- --remove-duplicate-keys
|
||||
# - repo: https://github.com/psf/black
|
||||
# rev: 22.8.0
|
||||
# hooks:
|
||||
# - id: black
|
||||
# - repo: https://github.com/pre-commit/mirrors-prettier
|
||||
# rev: v2.7.1
|
||||
# hooks:
|
||||
# - id: prettier
|
||||
# name: prettier (with plugin-xml)
|
||||
# additional_dependencies:
|
||||
# - "prettier@2.7.1"
|
||||
# - "@prettier/plugin-xml@2.2.0"
|
||||
# args:
|
||||
# - --plugin=@prettier/plugin-xml
|
||||
# files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
|
||||
# - repo: https://github.com/pre-commit/mirrors-eslint
|
||||
# rev: v8.24.0
|
||||
# hooks:
|
||||
# - id: eslint
|
||||
# verbose: true
|
||||
# args:
|
||||
# - --color
|
||||
# - --fix
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
# exclude autogenerated files
|
||||
exclude: lib/|.*\.rst|.*\.pot|README.*
|
||||
- id: end-of-file-fixer
|
||||
# exclude autogenerated files
|
||||
exclude: lib/|.*\.rst|.*\.pot|README.*
|
||||
- id: debug-statements
|
||||
- id: fix-encoding-pragma
|
||||
args: ["--remove"]
|
||||
- id: check-case-conflict
|
||||
- id: check-docstring-first
|
||||
# - id: check-executables-have-shebangs
|
||||
- id: check-merge-conflict
|
||||
# exclude files where underlines are not distinguishable from merge conflicts
|
||||
exclude: /README\.rst$|^docs/.*\.rst$
|
||||
- id: check-symlinks
|
||||
- id: check-xml
|
||||
# - id: mixed-line-ending
|
||||
# args: ["--fix=lf"]
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.38.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ["--keep-percent-format"]
|
||||
- repo: https://github.com/OCA/odoo-pre-commit-hooks
|
||||
rev: v0.0.27
|
||||
hooks:
|
||||
- id: oca-checks-odoo-module
|
||||
# - id: oca-checks-po
|
||||
# args: ["--fix"]
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort except __init__.py
|
||||
args:
|
||||
- --settings=.
|
||||
exclude: /__init__\.py$
|
||||
# - repo: https://github.com/acsone/setuptools-odoo
|
||||
# rev: 3.1.8
|
||||
# hooks:
|
||||
# - id: setuptools-odoo-make-default
|
||||
# - id: setuptools-odoo-get-requirements
|
||||
# args:
|
||||
# - --output
|
||||
# - requirements.txt
|
||||
# - --header
|
||||
# - "# generated from manifests external_dependencies"
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 6.1.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
name: flake8
|
||||
# args: ['--max-line-length=88','--ignore=E123,E133,E226,E241,E242,F811,F601,W503,W504,E203','--exclude=__unported__,__init__.py,__manifest__.py,examples']
|
||||
additional_dependencies: ["flake8-bugbear==23.9.16", "flake8-print==5.0.0", "importlib-metadata<6.0.0"]
|
||||
entry: flake8 --config=cfg_run_flake8.cfg
|
||||
- repo: https://github.com/OCA/pylint-odoo
|
||||
rev: v9.3.6
|
||||
hooks:
|
||||
# C8103
|
||||
# - id: pylint_odoo
|
||||
# name: pylint with optional checks
|
||||
# args:
|
||||
# - --rcfile=.pylintrc
|
||||
# - --exit-zero
|
||||
# verbose: true
|
||||
- id: pylint_odoo
|
||||
# entry: pylint --rcfile=cfg_run_pylint.cfg
|
||||
args:
|
||||
- --rcfile=cfg_run_pylint.cfg
|
||||
# args: ['--rcfile=cfg_run_pylint.cfg']
|
||||
178
backend/openeducat_erp-19.0/openeducat_erp-19.0/LICENSE
Normal file
178
backend/openeducat_erp-19.0/openeducat_erp-19.0/LICENSE
Normal file
@@ -0,0 +1,178 @@
|
||||
|
||||
For copyright information, please see the COPYRIGHT file.
|
||||
|
||||
OpenEduCat is published under the GNU LESSER GENERAL PUBLIC LICENSE, Version 3
|
||||
(LGPLv3), as included below. Since the LGPL is a set of additional
|
||||
permissions on top of the GPL, the text of the GPL is included at the
|
||||
bottom as well.
|
||||
|
||||
Some external libraries and contributions bundled with OpenEduCat may be
|
||||
publishedunder other GPL-compatible licenses. For these, please refer to the
|
||||
relevant source files and/or license files, in the source code tree.
|
||||
|
||||
**************************************************************************
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
115
backend/openeducat_erp-19.0/openeducat_erp-19.0/README.md
Normal file
115
backend/openeducat_erp-19.0/openeducat_erp-19.0/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# OpenEduCat Community Edition 🎓
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/openeducat/openeducat_erp/stargazers)
|
||||
|
||||
## Introduction 🚀
|
||||
|
||||
OpenEduCat is a powerful, feature-rich **Open Source Educational ERP** designed to streamline academic and administrative processes in educational institutions. Whether you’re managing admissions, academics, finance, or human resources, OpenEduCat provides an integrated platform that empowers your institution with flexibility and innovation. Join our community to transform education management and embrace the future of learning! 🌟
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Features 🚀📚](#features-)
|
||||
- [Demo & Live Links 🌐](#demo--live-links-)
|
||||
- [Installation](#installation)
|
||||
- [Documentation 📖](#documentation-)
|
||||
- [Community & Support 🤝](#community--support-)
|
||||
- [Roadmap 🗺️](#roadmap-)
|
||||
- [License 📄](#license-)
|
||||
- [Contact 📞](#contact-)
|
||||
|
||||
---
|
||||
|
||||
## Features 🚀📚
|
||||
|
||||
OpenEduCat offers a comprehensive suite of features tailored for modern educational institutions:
|
||||
|
||||
- **Admissions & Registration** 🎟️: Simplify enrollment and registration processes.
|
||||
- **Student Information Management** 👨🎓👩🎓: Manage student profiles, academic history, and personal details.
|
||||
- **Course & Batch Management** 📚: Organize courses, batches, and scheduling with ease.
|
||||
- **Examination Management** 📝: Streamline exam scheduling, evaluation, and result processing.
|
||||
- **Fee & Finance Management** 💰: Automate fee collection, invoicing, and financial reporting.
|
||||
- **Attendance & Timetable** ⏰: Keep track of attendance and manage class schedules efficiently.
|
||||
- **Library Management** 📖: Handle book lending, cataloging, and member management.
|
||||
- **Transport & Hostel Management** 🚍🏠: Oversee transportation logistics and hostel accommodations.
|
||||
- **Communication Tools** 📢: Enhance collaboration with integrated messaging and notifications.
|
||||
- **Reporting & Analytics** 📊: Generate insightful reports for data-driven decision-making.
|
||||
- **HR & Payroll Management** 👥: Manage staff records, payroll, and performance reviews.
|
||||
- **Customizable & Modular** 🔧: Adapt or extend modules to meet your institution’s unique needs.
|
||||
- **Secure & Scalable** 🔒: Robust security features ensure your data is protected while scaling with your growth.
|
||||
|
||||
For a full list of features, please visit our [Features Page](https://openeducat.org/features) 😊
|
||||
|
||||
---
|
||||
|
||||
## Demo & Live Links 🌐
|
||||
|
||||
Experience OpenEduCat firsthand:
|
||||
- **Online Demo**: [Try our live demo](https://openeducat.org/demo) 🎥
|
||||
- **Official Website**: [Visit OpenEduCat.org](https://openeducat.org) 🌟
|
||||
- **Community Meetings & Webinars**:
|
||||
- [Join our next community meeting](https://openeducat.org/meeting) 🤝
|
||||
- [Register for upcoming webinars](https://webinars.openeducat.org/events) 🎤
|
||||
|
||||
---
|
||||
|
||||
## Installation 🛠️
|
||||
|
||||
- Follow these steps to set up OpenEduCat Community Edition (https://doc.openeducat.org/administration/install.html)
|
||||
|
||||
---
|
||||
|
||||
## Documentation 📖
|
||||
|
||||
Learn more about OpenEduCat:
|
||||
- **Documentation Portal**: [OpenEduCat Documentation](https://doc.openeducat.org/)
|
||||
- **User Guides**: Comprehensive guides to help you master the system quickly.
|
||||
|
||||
---
|
||||
|
||||
## Community & Support 🤝
|
||||
|
||||
Join our active and vibrant community:
|
||||
- **Discussion Forum**: [OpenEduCat Forum](https://openeducat.org/forum)
|
||||
- **Issue Tracker**: Report bugs and request features on [GitHub Issues](https://github.com/openeducat/openeducat_erp/issues)
|
||||
- **Community Chat**: Connect with peers on our [Community Chat](https://community.openeducat.org)
|
||||
- **Social Media**:
|
||||
- LinkedIN: [@OpenEduCat Company Page](https://www.linkedin.com/company/openeducat-inc/)
|
||||
- Instagram: [@OpenEduCat Profile](https://www.instagram.com/openeducat)
|
||||
- Twitter: [@OpenEduCat](https://twitter.com/openeducat)
|
||||
- Facebook: [OpenEduCat Facebook Page](https://facebook.com/openeducat)
|
||||
|
||||
---
|
||||
|
||||
## Roadmap 🗺️
|
||||
|
||||
We’re continuously evolving! Here’s a glimpse of what’s coming:
|
||||
- **Enhanced Mobile Experience** 📱: Optimizing for a seamless mobile interface.
|
||||
- **New Modules** 🆕: Introducing additional modules based on community feedback.
|
||||
- **Performance Optimization** ⚡: Continuous improvements for faster and smoother operations.
|
||||
- **Extended Integrations** 🔗: More integrations with popular third-party services.
|
||||
- **User Experience Enhancements** 🎨: Regular UI/UX updates to make navigation even easier.
|
||||
|
||||
Stay tuned for future updates and contribute to shaping our roadmap!
|
||||
|
||||
---
|
||||
|
||||
## License 📄
|
||||
|
||||
OpenEduCat is distributed under the **LGPL-3.0 License**. See the [LICENSE](LICENSE) file for more details.
|
||||
|
||||
---
|
||||
|
||||
## Contact 📞
|
||||
|
||||
Have questions or need support? Get in touch:
|
||||
- **Email**: [support@openeducat.org](mailto:support@openeducat.org)
|
||||
- **Forum**: [OpenEduCat Forum](https://openeducat.org/forum)
|
||||
- **Twitter**: [@OpenEduCat](https://twitter.com/openeducat)
|
||||
|
||||
---
|
||||
|
||||
Thank you for choosing **OpenEduCat** – empowering educational institutions with open source technology. We appreciate your support and look forward to your contributions! 🙌
|
||||
|
||||
*Happy Learning & Coding! 💻🎉*
|
||||
@@ -0,0 +1,4 @@
|
||||
[flake8]
|
||||
ignore = E123,E133,E226,E241,E242,F811,F601,W503,W504,E203
|
||||
max-line-length = 88
|
||||
exclude = __unported__,__init__.py,__manifest__.py,examples
|
||||
@@ -0,0 +1,57 @@
|
||||
[MASTER]
|
||||
ignore=CVS,.git,scenarios,.bzr,static,openeducat_bigbluebutton
|
||||
persistent=yes
|
||||
load-plugins=pylint_odoo
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=all
|
||||
|
||||
# Reference of pylint-odoo messages:
|
||||
# https://github.com/OCA/pylint-odoo/blob/v9.3.6/pylint_odoo/checkers/no_modules.py
|
||||
|
||||
enable=C0901, # too-complex
|
||||
W0402, # deprecated-module
|
||||
W7901, # dangerous-filter-wo-user
|
||||
W7902, # duplicate-xml-record-id
|
||||
W7905, # create-user-wo-reset-password
|
||||
W7906, # duplicate-id-csv
|
||||
W7908, # missing-newline-extrafiles
|
||||
W7909, # redundant-modulename-xml
|
||||
W7910, # wrong-tabs-instead-of-spaces
|
||||
W7930, # file-not-used
|
||||
W7940, # dangerous-view-replace-wo-priority
|
||||
W7950, # odoo-addons-relative-import
|
||||
W8101, # api-one-multi-together
|
||||
W8102, # copy-wo-api-one
|
||||
W8103, # translation-field
|
||||
W8104, # api-one-deprecated
|
||||
W8106, # method-required-super
|
||||
W8201, # incoherent-interpreter-exec-perm
|
||||
W8202, # use-vim-comment
|
||||
C8103, # manifest-deprecated-key
|
||||
C8104, # class-camelcase
|
||||
C8108, # method-compute
|
||||
C8109, # method-search
|
||||
C8110, # method-inverse
|
||||
C8201, # no-utf8-coding-comment
|
||||
R7980, # consider-merging-classes-inherited
|
||||
R8101, # openerp-exception-warning
|
||||
R8110, # old-api7-method-defined
|
||||
E7902, # xml-syntax-error
|
||||
pointless-string-statement,
|
||||
redefined-builtin
|
||||
|
||||
[REPORTS]
|
||||
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
|
||||
output-format=colorized
|
||||
reports=no
|
||||
|
||||
[FORMAT]
|
||||
indent-string=' '
|
||||
|
||||
[SIMILARITIES]
|
||||
ignore-comments=yes
|
||||
ignore-docstrings=yes
|
||||
|
||||
[MISCELLANEOUS]
|
||||
notes=
|
||||
@@ -0,0 +1 @@
|
||||
This module provide feature of Activity Management.
|
||||
@@ -0,0 +1,22 @@
|
||||
###############################################################################
|
||||
#
|
||||
# OpenEduCat Inc
|
||||
# Copyright (C) 2009-TODAY OpenEduCat Inc(<https://www.openeducat.org>).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
@@ -0,0 +1,51 @@
|
||||
###############################################################################
|
||||
#
|
||||
# OpenEduCat Inc
|
||||
# Copyright (C) 2009-TODAY OpenEduCat Inc(<https://www.openeducat.org>).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
{
|
||||
'name': 'OpenEduCat Activity',
|
||||
'version': '19.0.1.0',
|
||||
'license': 'LGPL-3',
|
||||
'category': 'Education',
|
||||
"sequence": 3,
|
||||
'summary': 'Manage Activities',
|
||||
'complexity': "easy",
|
||||
'author': 'OpenEduCat Inc',
|
||||
'website': 'https://www.openeducat.org',
|
||||
'depends': ['openeducat_core'],
|
||||
'data': [
|
||||
'security/op_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/activity_type_data.xml',
|
||||
'wizard/student_migrate_wizard_view.xml',
|
||||
'views/activity_view.xml',
|
||||
'views/activity_type_view.xml',
|
||||
'views/student_view.xml',
|
||||
'menus/op_menu.xml'
|
||||
],
|
||||
'demo': [
|
||||
'demo/activity_demo.xml',
|
||||
],
|
||||
'images': [
|
||||
'static/description/openeducat-activity_banner.jpg',
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': True,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="op_activity_type_1" model="op.activity.type">
|
||||
<field name="name">Presentation</field>
|
||||
</record>
|
||||
<record id="op_activity_type_2" model="op.activity.type">
|
||||
<field name="name">Late Application</field>
|
||||
</record>
|
||||
<record id="op_activity_type_3" model="op.activity.type">
|
||||
<field name="name">Migration</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="op_activity_1" model="op.activity">
|
||||
<field name="description">Good presentation given</field>
|
||||
<field name="date"
|
||||
eval="(DateTime.today() - relativedelta(months=1)).strftime('%Y-%m-14 %H:%M')" />
|
||||
<field name="faculty_id" ref="openeducat_core.op_faculty_1" />
|
||||
<field name="student_id" ref="openeducat_core.op_student_1" />
|
||||
<field name="type_id" ref="op_activity_type_1" />
|
||||
</record>
|
||||
|
||||
<record id="op_activity_2" model="op.activity">
|
||||
<field name="description">Reached 1 hour late</field>
|
||||
<field name="date"
|
||||
eval="(DateTime.today() - relativedelta(months=1)).strftime('%Y-%m-12 %H:%M')" />
|
||||
<field name="faculty_id" ref="openeducat_core.op_faculty_2" />
|
||||
<field name="student_id" ref="openeducat_core.op_student_2" />
|
||||
<field name="type_id" ref="op_activity_type_2" />
|
||||
</record>
|
||||
|
||||
<record id="openeducat_core.op_user_faculty" model="res.users">
|
||||
<field name="group_ids"
|
||||
eval="[(4,ref('openeducat_activity.group_activity_user'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,448 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * openeducat_activity
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 19.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
|
||||
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
|
||||
msgid "Academic Year"
|
||||
msgstr "العام الدراسي"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
|
||||
msgid "Action Needed"
|
||||
msgstr "الإجراء مطلوب"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
|
||||
msgid "Active"
|
||||
msgstr "نشيط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
|
||||
msgid "Activities"
|
||||
msgstr "أنشطة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
|
||||
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
|
||||
msgid "Activity"
|
||||
msgstr "نشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
|
||||
msgid "Activity Count"
|
||||
msgstr "عدد النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
|
||||
msgid "Activity Exception Decoration"
|
||||
msgstr "زخرفة استثناء النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
|
||||
msgid "Activity Graph View"
|
||||
msgstr "عرض الرسم البياني للنشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
|
||||
msgid "Activity Log"
|
||||
msgstr "سجل النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
|
||||
msgid "Activity Logs"
|
||||
msgstr "سجلات النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
|
||||
msgid "Activity Pivot"
|
||||
msgstr "محور النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
|
||||
msgid "Activity State"
|
||||
msgstr "حالة النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_activity_type
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
|
||||
msgid "Activity Type"
|
||||
msgstr "نوع النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
|
||||
msgid "Activity Type Icon"
|
||||
msgstr "رمز نوع النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
|
||||
msgid "Activity Types"
|
||||
msgstr "أنواع النشاط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
|
||||
msgid "Activity type must be unique!"
|
||||
msgstr "يجب أن يكون نوع النشاط فريدًا!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
|
||||
msgid "Archived"
|
||||
msgstr "مؤرشف"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
|
||||
msgid "Attachment Count"
|
||||
msgstr "عدد المرفقات"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, As selected courses don't share same Program!"
|
||||
msgstr ""
|
||||
"لا يمكن الترحيل، لأن الدورات التدريبية المحددة لا تشترك في نفس البرنامج!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, As selected courses don't share same parent course!"
|
||||
msgstr ""
|
||||
"لا يمكن الترحيل، نظرًا لأن الدورات التدريبية المحددة لا تشترك في نفس الدورة "
|
||||
"التدريبية الأصلية!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, Proceed for new admission"
|
||||
msgstr "لا يمكن الهجرة، تابع للحصول على قبول جديد"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Cancel"
|
||||
msgstr "يلغي"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
|
||||
msgid "Course Completed?"
|
||||
msgstr "اكتملت الدورة؟"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "تم إنشاؤها بواسطة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
|
||||
msgid "Created on"
|
||||
msgstr "تم الإنشاء بتاريخ"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
|
||||
msgid "Date"
|
||||
msgstr "تاريخ"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
msgid "Description"
|
||||
msgstr "وصف"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "اسم العرض"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
|
||||
msgid "Faculty"
|
||||
msgstr "كلية"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
|
||||
msgid "Followers"
|
||||
msgstr "المتابعون"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
|
||||
msgid "Followers (Partners)"
|
||||
msgstr "المتابعون (الشركاء)"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
|
||||
msgid "Font awesome icon e.g. fa-tasks"
|
||||
msgstr "رمز الخط الرائع على سبيل المثال. مهام كرة القدم"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Forward"
|
||||
msgstr "إلى الأمام"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
|
||||
msgid "From Course"
|
||||
msgstr "من الدورة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "From Course must not be same as To Course!"
|
||||
msgstr "يجب ألا يكون \"من الدورة التدريبية\" هو نفسه \"إلى الدورة التدريبية\"!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
|
||||
msgid "Has Message"
|
||||
msgstr "لديه رسالة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
|
||||
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
|
||||
msgid "Helps you manage your institutes different-different users."
|
||||
msgstr "يساعدك على إدارة معاهدك بمستخدمين مختلفين."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
|
||||
msgid "ID"
|
||||
msgstr "بطاقة تعريف"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
|
||||
msgid "Icon"
|
||||
msgstr "رمز"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
|
||||
msgid "Icon to indicate an exception activity."
|
||||
msgstr "أيقونة للإشارة إلى نشاط الاستثناء."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
|
||||
msgid "If checked, new messages require your attention."
|
||||
msgstr "إذا تم تحديدها، فإن الرسائل الجديدة تتطلب انتباهك."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
|
||||
msgid "If checked, some messages have a delivery error."
|
||||
msgstr "إذا تم تحديده، فإن بعض الرسائل تحتوي على خطأ في التسليم."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
|
||||
msgid "Is Follower"
|
||||
msgstr "هو تابع"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "آخر تحديث بواسطة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "آخر تحديث بتاريخ"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups,name:openeducat_activity.group_activity_manager
|
||||
msgid "Manager"
|
||||
msgstr "مدير"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
|
||||
msgid "Message Delivery error"
|
||||
msgstr "خطأ في تسليم الرسالة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
|
||||
msgid "Messages"
|
||||
msgstr "رسائل"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
|
||||
msgid "My Activity Deadline"
|
||||
msgstr "الموعد النهائي لنشاطي"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
|
||||
msgid "Name"
|
||||
msgstr "اسم"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
|
||||
msgid "Next Activity Deadline"
|
||||
msgstr "الموعد النهائي للنشاط التالي"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
|
||||
msgid "Next Activity Summary"
|
||||
msgstr "ملخص النشاط التالي"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
|
||||
msgid "Next Activity Type"
|
||||
msgstr "نوع النشاط التالي"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
|
||||
msgid "Number of Actions"
|
||||
msgstr "عدد الإجراءات"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
|
||||
msgid "Number of errors"
|
||||
msgstr "عدد الأخطاء"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
|
||||
msgid "Number of messages requiring action"
|
||||
msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
|
||||
msgid "Number of messages with delivery error"
|
||||
msgstr "عدد الرسائل التي بها خطأ في التسليم"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
|
||||
msgid "Openeducat Activity Privilege"
|
||||
msgstr "امتياز نشاط Openeducat"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
|
||||
msgid "Optional Subjects"
|
||||
msgstr "مواضيع اختيارية"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
|
||||
msgid "Responsible User"
|
||||
msgstr "المستخدم المسؤول"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
|
||||
msgid "SMS Delivery error"
|
||||
msgstr "خطأ في تسليم الرسائل القصيرة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
|
||||
msgid ""
|
||||
"Status based on activities\n"
|
||||
"Overdue: Due date is already passed\n"
|
||||
"Today: Activity date is today\n"
|
||||
"Planned: Future activities."
|
||||
msgstr ""
|
||||
"الحالة على أساس الأنشطة\n"
|
||||
"متأخر: لقد مر تاريخ الاستحقاق بالفعل\n"
|
||||
"اليوم: تاريخ النشاط هو اليوم\n"
|
||||
"المخطط: الأنشطة المستقبلية."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_student
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
|
||||
msgid "Student"
|
||||
msgstr "طالب"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_activity
|
||||
msgid "Student Activity"
|
||||
msgstr "النشاط الطلابي"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
|
||||
msgid "Student Ids Domain"
|
||||
msgstr "مجال معرفات الطلاب"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_student_migrate
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Student Migrate"
|
||||
msgstr "هجرة الطالب"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
|
||||
msgid "Student Migration"
|
||||
msgstr "الهجرة الطلابية"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Student(s)"
|
||||
msgstr "طلاب)"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
|
||||
msgid "Terms"
|
||||
msgstr "شروط"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
|
||||
msgid "To Batch"
|
||||
msgstr "إلى الدفعة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
|
||||
msgid "To Course"
|
||||
msgstr "إلى الدورة"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
|
||||
msgid "Type of the exception activity on record."
|
||||
msgstr "نوع نشاط الاستثناء المسجل."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups,name:openeducat_activity.group_activity_user
|
||||
msgid "User"
|
||||
msgstr "مستخدم"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
|
||||
msgid "Valid To Courses"
|
||||
msgstr "صالحة للدورات"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
|
||||
msgid "Website Messages"
|
||||
msgstr "رسائل الموقع"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
|
||||
msgid "Website communication history"
|
||||
msgstr "تاريخ اتصالات الموقع"
|
||||
@@ -0,0 +1,447 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * openeducat_activity
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 19.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
|
||||
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
|
||||
msgid "Academic Year"
|
||||
msgstr "Akademisk år"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
|
||||
msgid "Action Needed"
|
||||
msgstr "Handling nødvendig"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
|
||||
msgid "Activities"
|
||||
msgstr "Aktiviteter"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
|
||||
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
|
||||
msgid "Activity"
|
||||
msgstr "Aktivitet"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
|
||||
msgid "Activity Count"
|
||||
msgstr "Aktivitetstælling"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
|
||||
msgid "Activity Exception Decoration"
|
||||
msgstr "Aktivitet Undtagelse Dekoration"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
|
||||
msgid "Activity Graph View"
|
||||
msgstr "Visning af aktivitetsgraf"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
|
||||
msgid "Activity Log"
|
||||
msgstr "Aktivitetslog"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
|
||||
msgid "Activity Logs"
|
||||
msgstr "Aktivitetslogs"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
|
||||
msgid "Activity Pivot"
|
||||
msgstr "Aktivitetspivot"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
|
||||
msgid "Activity State"
|
||||
msgstr "Aktivitetstilstand"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_activity_type
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
|
||||
msgid "Activity Type"
|
||||
msgstr "Aktivitetstype"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
|
||||
msgid "Activity Type Icon"
|
||||
msgstr "Ikon for aktivitetstype"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
|
||||
msgid "Activity Types"
|
||||
msgstr "Aktivitetstyper"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
|
||||
msgid "Activity type must be unique!"
|
||||
msgstr "Aktivitetstypen skal være unik!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
|
||||
msgid "Archived"
|
||||
msgstr "Arkiveret"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
|
||||
msgid "Attachment Count"
|
||||
msgstr "Antal vedhæftede filer"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, As selected courses don't share same Program!"
|
||||
msgstr "Kan ikke migrere, da udvalgte kurser ikke deler samme program!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, As selected courses don't share same parent course!"
|
||||
msgstr "Kan ikke migrere, da udvalgte kurser ikke deler samme forældrekursus!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, Proceed for new admission"
|
||||
msgstr "Kan ikke migrere. Fortsæt for ny optagelse"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Cancel"
|
||||
msgstr "Ophæve"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
|
||||
msgid "Course Completed?"
|
||||
msgstr "Kurset gennemført?"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "Skabt af"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
|
||||
msgid "Created on"
|
||||
msgstr "Oprettet på"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
|
||||
msgid "Date"
|
||||
msgstr "Dato"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
msgid "Description"
|
||||
msgstr "Beskrivelse"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "Vist navn"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
|
||||
msgid "Faculty"
|
||||
msgstr "Fakultet"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
|
||||
msgid "Followers"
|
||||
msgstr "Tilhængere"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
|
||||
msgid "Followers (Partners)"
|
||||
msgstr "Følgere (partnere)"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
|
||||
msgid "Font awesome icon e.g. fa-tasks"
|
||||
msgstr "Font fantastisk ikon f.eks. fa-opgaver"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Forward"
|
||||
msgstr "Forward"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
|
||||
msgid "From Course"
|
||||
msgstr "Fra Kursus"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "From Course must not be same as To Course!"
|
||||
msgstr "Fra kursus må ikke være det samme som Til kursus!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
|
||||
msgid "Has Message"
|
||||
msgstr "Har besked"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
|
||||
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
|
||||
msgid "Helps you manage your institutes different-different users."
|
||||
msgstr ""
|
||||
"Hjælper dig med at administrere dine institutter forskellige-forskellige "
|
||||
"brugere."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
|
||||
msgid "ID"
|
||||
msgstr "ID"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
|
||||
msgid "Icon"
|
||||
msgstr "Ikon"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
|
||||
msgid "Icon to indicate an exception activity."
|
||||
msgstr "Ikon for at angive en undtagelsesaktivitet."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
|
||||
msgid "If checked, new messages require your attention."
|
||||
msgstr "Hvis markeret, kræver nye beskeder din opmærksomhed."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
|
||||
msgid "If checked, some messages have a delivery error."
|
||||
msgstr "Hvis markeret, har nogle meddelelser en leveringsfejl."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
|
||||
msgid "Is Follower"
|
||||
msgstr "Er Tilhænger"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "Sidst opdateret af"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "Sidst opdateret den"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups,name:openeducat_activity.group_activity_manager
|
||||
msgid "Manager"
|
||||
msgstr "Manager"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
|
||||
msgid "Message Delivery error"
|
||||
msgstr "Fejl ved levering af besked"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
|
||||
msgid "Messages"
|
||||
msgstr "Beskeder"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
|
||||
msgid "My Activity Deadline"
|
||||
msgstr "Deadline for min aktivitet"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
|
||||
msgid "Name"
|
||||
msgstr "Navn"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
|
||||
msgid "Next Activity Deadline"
|
||||
msgstr "Næste aktivitetsfrist"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
|
||||
msgid "Next Activity Summary"
|
||||
msgstr "Næste aktivitetsoversigt"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
|
||||
msgid "Next Activity Type"
|
||||
msgstr "Næste aktivitetstype"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
|
||||
msgid "Number of Actions"
|
||||
msgstr "Antal handlinger"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
|
||||
msgid "Number of errors"
|
||||
msgstr "Antal fejl"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
|
||||
msgid "Number of messages requiring action"
|
||||
msgstr "Antal meddelelser, der kræver handling"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
|
||||
msgid "Number of messages with delivery error"
|
||||
msgstr "Antal meddelelser med leveringsfejl"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
|
||||
msgid "Openeducat Activity Privilege"
|
||||
msgstr "Openeducat Aktivitetsprivilegium"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
|
||||
msgid "Optional Subjects"
|
||||
msgstr "Valgfrie emner"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
|
||||
msgid "Responsible User"
|
||||
msgstr "Ansvarlig bruger"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
|
||||
msgid "SMS Delivery error"
|
||||
msgstr "SMS-leveringsfejl"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
|
||||
msgid ""
|
||||
"Status based on activities\n"
|
||||
"Overdue: Due date is already passed\n"
|
||||
"Today: Activity date is today\n"
|
||||
"Planned: Future activities."
|
||||
msgstr ""
|
||||
"Status baseret på aktiviteter\n"
|
||||
"Forfalden: Forfaldsdatoen er allerede overskredet\n"
|
||||
"I dag: Aktivitetsdatoen er i dag\n"
|
||||
"Planlagt: Fremtidige aktiviteter."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_student
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
|
||||
msgid "Student"
|
||||
msgstr "Studerende"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_activity
|
||||
msgid "Student Activity"
|
||||
msgstr "Elevaktivitet"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
|
||||
msgid "Student Ids Domain"
|
||||
msgstr "Student IDs domæne"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_student_migrate
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Student Migrate"
|
||||
msgstr "Studenter migrerer"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
|
||||
msgid "Student Migration"
|
||||
msgstr "Studenter migration"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Student(s)"
|
||||
msgstr "Elev(er)"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
|
||||
msgid "Terms"
|
||||
msgstr "Vilkår"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
|
||||
msgid "To Batch"
|
||||
msgstr "Til Batch"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
|
||||
msgid "To Course"
|
||||
msgstr "Til kursus"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
|
||||
msgid "Type of the exception activity on record."
|
||||
msgstr "Type af den registrerede undtagelsesaktivitet."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups,name:openeducat_activity.group_activity_user
|
||||
msgid "User"
|
||||
msgstr "Bruger"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
|
||||
msgid "Valid To Courses"
|
||||
msgstr "Gælder for kurser"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
|
||||
msgid "Website Messages"
|
||||
msgstr "Hjemmeside beskeder"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
|
||||
msgid "Website communication history"
|
||||
msgstr "Hjemmeside kommunikation historie"
|
||||
@@ -0,0 +1,454 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * openeducat_activity
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 19.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
|
||||
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
|
||||
msgid "Academic Year"
|
||||
msgstr "Akademisches Jahr"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
|
||||
msgid "Action Needed"
|
||||
msgstr "Handlungsbedarf"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
|
||||
msgid "Activities"
|
||||
msgstr "Aktivitäten"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
|
||||
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
|
||||
msgid "Activity"
|
||||
msgstr "Aktivität"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
|
||||
msgid "Activity Count"
|
||||
msgstr "Aktivitätsanzahl"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
|
||||
msgid "Activity Exception Decoration"
|
||||
msgstr "Aktivitäts-Ausnahmedekoration"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
|
||||
msgid "Activity Graph View"
|
||||
msgstr "Aktivitätsdiagrammansicht"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
|
||||
msgid "Activity Log"
|
||||
msgstr "Aktivitätsprotokoll"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
|
||||
msgid "Activity Logs"
|
||||
msgstr "Aktivitätsprotokolle"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
|
||||
msgid "Activity Pivot"
|
||||
msgstr "Aktivitäts-Pivot"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
|
||||
msgid "Activity State"
|
||||
msgstr "Aktivitätsstatus"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_activity_type
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
|
||||
msgid "Activity Type"
|
||||
msgstr "Aktivitätstyp"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
|
||||
msgid "Activity Type Icon"
|
||||
msgstr "Aktivitätstyp-Symbol"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
|
||||
msgid "Activity Types"
|
||||
msgstr "Aktivitätstypen"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
|
||||
msgid "Activity type must be unique!"
|
||||
msgstr "Der Aktivitätstyp muss eindeutig sein!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
|
||||
msgid "Archived"
|
||||
msgstr "Archiviert"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
|
||||
msgid "Attachment Count"
|
||||
msgstr "Anzahl der Anhänge"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, As selected courses don't share same Program!"
|
||||
msgstr ""
|
||||
"Die Migration ist nicht möglich, da die ausgewählten Kurse nicht dasselbe "
|
||||
"Programm haben!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, As selected courses don't share same parent course!"
|
||||
msgstr ""
|
||||
"Die Migration ist nicht möglich, da ausgewählte Kurse nicht denselben "
|
||||
"übergeordneten Kurs haben!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "Can't migrate, Proceed for new admission"
|
||||
msgstr "Die Migration ist nicht möglich. Fahren Sie mit der Neuaufnahme fort"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Cancel"
|
||||
msgstr "Stornieren"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
|
||||
msgid "Course Completed?"
|
||||
msgstr "Kurs abgeschlossen?"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "Erstellt von"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
|
||||
msgid "Created on"
|
||||
msgstr "Erstellt am"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
|
||||
msgid "Date"
|
||||
msgstr "Datum"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
|
||||
msgid "Description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "Anzeigename"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
|
||||
msgid "Faculty"
|
||||
msgstr "Fakultät"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
|
||||
msgid "Followers"
|
||||
msgstr "Anhänger"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
|
||||
msgid "Followers (Partners)"
|
||||
msgstr "Follower (Partner)"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
|
||||
msgid "Font awesome icon e.g. fa-tasks"
|
||||
msgstr "Fantastisches Schriftartensymbol, z. B. Fett-Aufgaben"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Forward"
|
||||
msgstr "Nach vorne"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
|
||||
msgid "From Course"
|
||||
msgstr "Vom Kurs"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#. odoo-python
|
||||
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
|
||||
msgid "From Course must not be same as To Course!"
|
||||
msgstr "Von Kurs darf nicht mit Zu Kurs identisch sein!"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
|
||||
msgid "Has Message"
|
||||
msgstr "Hat Nachricht"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
|
||||
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
|
||||
msgid "Helps you manage your institutes different-different users."
|
||||
msgstr ""
|
||||
"Hilft Ihnen, die unterschiedlichen Benutzer Ihrer Institute zu verwalten."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
|
||||
msgid "ID"
|
||||
msgstr "AUSWEIS"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
|
||||
msgid "Icon"
|
||||
msgstr "Symbol"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
|
||||
msgid "Icon to indicate an exception activity."
|
||||
msgstr "Symbol zur Anzeige einer Ausnahmeaktivität."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
|
||||
msgid "If checked, new messages require your attention."
|
||||
msgstr ""
|
||||
"Wenn diese Option aktiviert ist, erfordern neue Nachrichten Ihre "
|
||||
"Aufmerksamkeit."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
|
||||
msgid "If checked, some messages have a delivery error."
|
||||
msgstr ""
|
||||
"Wenn diese Option aktiviert ist, liegt bei einigen Nachrichten ein "
|
||||
"Zustellungsfehler vor."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
|
||||
msgid "Is Follower"
|
||||
msgstr "Ist Follower"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "Zuletzt aktualisiert von"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "Zuletzt aktualisiert am"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups,name:openeducat_activity.group_activity_manager
|
||||
msgid "Manager"
|
||||
msgstr "Manager"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
|
||||
msgid "Message Delivery error"
|
||||
msgstr "Fehler bei der Nachrichtenzustellung"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
|
||||
msgid "Messages"
|
||||
msgstr "Nachrichten"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
|
||||
msgid "My Activity Deadline"
|
||||
msgstr "Meine Aktivitätsfrist"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
|
||||
msgid "Next Activity Deadline"
|
||||
msgstr "Frist für die nächste Aktivität"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
|
||||
msgid "Next Activity Summary"
|
||||
msgstr "Zusammenfassung der nächsten Aktivität"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
|
||||
msgid "Next Activity Type"
|
||||
msgstr "Nächster Aktivitätstyp"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
|
||||
msgid "Number of Actions"
|
||||
msgstr "Anzahl der Aktionen"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
|
||||
msgid "Number of errors"
|
||||
msgstr "Anzahl der Fehler"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
|
||||
msgid "Number of messages requiring action"
|
||||
msgstr "Anzahl der Nachrichten, die Maßnahmen erfordern"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
|
||||
msgid "Number of messages with delivery error"
|
||||
msgstr "Anzahl der Nachrichten mit Zustellungsfehlern"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
|
||||
msgid "Openeducat Activity Privilege"
|
||||
msgstr "Openeducat-Aktivitätsprivileg"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
|
||||
msgid "Optional Subjects"
|
||||
msgstr "Wahlfächer"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
|
||||
msgid "Responsible User"
|
||||
msgstr "Verantwortlicher Benutzer"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
|
||||
msgid "SMS Delivery error"
|
||||
msgstr "Fehler bei der SMS-Zustellung"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
|
||||
msgid ""
|
||||
"Status based on activities\n"
|
||||
"Overdue: Due date is already passed\n"
|
||||
"Today: Activity date is today\n"
|
||||
"Planned: Future activities."
|
||||
msgstr ""
|
||||
"Status basierend auf Aktivitäten\n"
|
||||
"Überfällig: Fälligkeitsdatum ist bereits überschritten\n"
|
||||
"Heute: Aktivitätsdatum ist heute\n"
|
||||
"Geplant: Zukünftige Aktivitäten."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_student
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
|
||||
msgid "Student"
|
||||
msgstr "Student"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_op_activity
|
||||
msgid "Student Activity"
|
||||
msgstr "Schüleraktivität"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
|
||||
msgid "Student Ids Domain"
|
||||
msgstr "Domäne für Studentenausweise"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model,name:openeducat_activity.model_student_migrate
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Student Migrate"
|
||||
msgstr "Studentenmigration"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
|
||||
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
|
||||
msgid "Student Migration"
|
||||
msgstr "Studentenmigration"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
|
||||
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
|
||||
msgid "Student(s)"
|
||||
msgstr "Student(en)"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
|
||||
msgid "Terms"
|
||||
msgstr "Bedingungen"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
|
||||
msgid "To Batch"
|
||||
msgstr "Zum Stapeln"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
|
||||
msgid "To Course"
|
||||
msgstr "Zum Kurs"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
|
||||
msgid "Type of the exception activity on record."
|
||||
msgstr "Typ der erfassten Ausnahmeaktivität."
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:res.groups,name:openeducat_activity.group_activity_user
|
||||
msgid "User"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
|
||||
msgid "Valid To Courses"
|
||||
msgstr "Gültig für Kurse"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
|
||||
msgid "Website Messages"
|
||||
msgstr "Website-Nachrichten"
|
||||
|
||||
#. module: openeducat_activity
|
||||
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
|
||||
msgid "Website communication history"
|
||||
msgstr "Kommunikationsverlauf der Website"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user