Merge pull request 'v4' (#4) from v4 into main
Some checks failed
CI / Frontend — lint + build + e2e (push) Has been cancelled
CI / Backend — Odoo HttpCase (push) Has been cancelled

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-04-19 12:33:30 +02:00
293 changed files with 36898 additions and 2951 deletions

View File

@@ -0,0 +1,30 @@
---
description: EnCoach project context — auto-load summary and update it at end of session
alwaysApply: true
---
# EnCoach Project Context
At the **start of every chat**, read `docs/PROJECT_SUMMARY.md` to understand the project state, credentials, architecture, API routes, and how to run the project. This is your primary reference for the EnCoach platform.
At the **end of every chat** (when the user says goodbye, asks to wrap up, or the session's main task is complete), update `docs/PROJECT_SUMMARY.md` with any new information discovered or changes made during the session. This includes:
- New or changed user credentials
- New API routes or changed endpoints
- New models or schema changes
- New modules installed or removed
- Git state changes (new branches, remotes, pushes)
- Bug fixes or known issues discovered
- New pages or components added to frontend
- Any configuration changes (odoo.conf, .env, etc.)
## Key Facts
- **Backend**: Odoo 19 at `http://127.0.0.1:8069`
- **Frontend**: Vite React at `http://localhost:8080`
- **Database**: `encoach_v2` on PostgreSQL 18
- **Login route**: `POST /api/login` (not `/api/auth/login`)
- **PostgreSQL binary**: `/Users/yamenahmad/micromamba/envs/odoo19/bin/pg_ctl` (PG 18 — must use this, not conda PG 17)
- **Node.js**: `/Users/yamenahmad/.local/node/bin/` (must add to PATH)
- **odoo.conf must have**: `db_name = encoach_v2` and `dbfilter = ^encoach_v2$`
- **Git branch**: `v4`

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

@@ -0,0 +1,109 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# --------------------------------------------------------------------
# Frontend: type-check, lint, build, and run Playwright smoke tests.
# --------------------------------------------------------------------
frontend:
name: Frontend — lint + build + e2e
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit -p tsconfig.app.json
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright smoke tests
run: npm run test:e2e
env:
CI: "true"
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report
retention-days: 7
# --------------------------------------------------------------------
# Backend: run the Odoo HttpCase smoke suite via the official image.
#
# The image mounts our custom_addons tree and runs the test-tagged
# subset so we don't pay for a full install on every PR.
# --------------------------------------------------------------------
backend:
name: Backend — Odoo HttpCase
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U odoo"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Run Odoo HttpCase tests in a throwaway container
run: |
docker run --rm \
--network host \
-v "$GITHUB_WORKSPACE/backend/custom_addons:/mnt/extra-addons" \
-e HOST=localhost \
-e USER=odoo \
-e PASSWORD=odoo \
odoo:19 \
--stop-after-init \
--test-enable \
--test-tags "encoach_api" \
--log-level=warn \
--without-demo=all \
--init=encoach_api,encoach_core \
-d encoach_ci || {
echo "::warning::Odoo HttpCase suite failed — see logs above."
exit 1
}
env:
PGHOST: localhost
PGUSER: odoo
PGPASSWORD: odoo

6
.gitignore vendored
View File

@@ -20,6 +20,12 @@ miniconda3/
# PostgreSQL data
pgdata/
pgdata_bak_*/
# Frontend build cache
frontend/.vite/
frontend/dist/
frontend/node_modules/
# IDE
.vscode/

View File

@@ -3,8 +3,11 @@ FROM odoo:19.0
USER root
# Install all Python dependencies required by EnCoach custom addons.
# --ignore-installed: skip uninstalling system packages that lack RECORD files (e.g. Debian's typing_extensions).
COPY new_project/requirements.txt /tmp/requirements.txt
# --ignore-installed: skip uninstalling system packages that lack RECORD files
# (e.g. Debian's typing_extensions).
# Canonical source: repo-root requirements.txt (the legacy copy under
# new_project/ is no longer authoritative — see new_project/DEPRECATED.md).
COPY requirements.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt --break-system-packages --no-warn-script-location --ignore-installed
USER odoo

267
README.md
View File

@@ -1,123 +1,190 @@
# EnCoach Backend v2 — Odoo 19 Adaptive Learning Platform
# EnCoach Adaptive AI Learning Platform
The Odoo 19 backend for the **EnCoach** adaptive learning and institutional LMS platform. Provides ~377 REST API endpoints serving a React 18 SPA frontend.
EnCoach is a smart learning environment for individual and collaborative learning,
fully integrated with AI and equipped with intelligent, professional-grade
exercises, assessments, and e-exams.
## System Overview
This repository hosts the full stack:
| Component | Detail |
|-----------|--------|
| **Framework** | Odoo 19 Community Edition |
| **Language** | Python 3.12+ |
| **Database** | PostgreSQL 16 |
| **LMS Foundation** | OpenEduCat Community (14 modules, ported to v19) |
| **Custom Modules** | 27 `encoach_*` modules |
| **AI/ML** | OpenAI GPT-4o, Whisper (local), AWS Polly, ELAI, GPTZero, FAISS |
| **Authentication** | JWT (PyJWT) |
| **Deployment** | Docker Compose |
- **Backend** — Odoo 19 + ~30 `encoach_*` modules (Python 3.12, PostgreSQL 16)
- **Frontend** — React 18 + Vite + TypeScript single-page app
- **AI layer** — OpenAI + pgvector RAG, quality-gate validation, IELTS validator
- **Ops** — Docker Compose, JWT auth with refresh tokens, Prometheus-compatible
metrics, dynamic OpenAPI 3.0 spec
## Module Architecture
> **Canonical trees:** `backend/` (all server code) and `frontend/` (all client
> code). The legacy `new_project/` directory is deprecated — see
> [`new_project/DEPRECATED.md`](new_project/DEPRECATED.md).
```
new_project/
custom_addons/ # 27 custom EnCoach modules
encoach_api/ # REST API controllers (~120 routes)
encoach_lms_api/ # OpenEduCat bridge API (~209 routes)
encoach_adaptive_api/ # Adaptive learning API (~41 routes)
encoach_resources/ # Resource library API (~7 routes)
encoach_core/ # Users, entities, roles, permissions
encoach_taxonomy/ # Subject > Domain > Topic taxonomy
encoach_adaptive/ # Proficiency, learning plans, mastery
encoach_adaptive_ai/ # AI for diagnostics, coaching, content
encoach_exam/ # AI-powered exam engine
encoach_ai/ # OpenAI service wrapper
encoach_ai_generation/ # AI exam content generation
encoach_ai_grading/ # AI grading (writing, speaking, math, IT)
encoach_ai_media/ # TTS (Polly), STT (Whisper), ELAI avatars
encoach_courseware/ # Chapters, materials, AI workbench
encoach_communication/ # Discussion boards, announcements, messaging
encoach_notification/ # Notification engine, rules, preferences
encoach_faq/ # FAQ categories and items
encoach_approval/ # Approval workflows, stages
encoach_assignment/ # Exam-wrapper assignments
encoach_classroom/ # Virtual classroom groups
encoach_stats/ # Session tracking, statistics
encoach_training/ # Training tips, walkthroughs, FAISS
encoach_subscription/ # Packages, Stripe/PayPal/Paymob
encoach_registration/ # User registration, batch import
encoach_ticket/ # Support tickets
encoach_sis/ # UTAS SIS integration
encoach_branding/ # Whitelabeling
openeducat_erp-19.0/ # 14 OpenEduCat community modules
openeducat_core/ # Courses, batches, students, faculty
openeducat_timetable/ # Timetable sessions
openeducat_attendance/ # Attendance management
openeducat_exam/ # Institutional exams, marksheets
openeducat_assignment/ # Course assignments, submissions
openeducat_admission/ # Admission registers, applications
openeducat_classroom/ # Physical classrooms
openeducat_facility/ # Facilities and assets
openeducat_fees/ # Fee plans, student fees
openeducat_library/ # Library media, movements, cards
openeducat_activity/ # Student activities
openeducat_parent/ # Parent-student relationships
openeducat_erp/ # ERP connector
theme_web_openeducat/ # Web theme
```
---
## Staging
## 1. Quickstart
| Service | URL |
|---------|-----|
| **Odoo Backend** | `http://5.189.151.117:8069` |
| **React Frontend** | `http://5.189.151.117:3000` |
## Branching Workflow
This repo is connected to the staging server via a Git post-receive hook.
**All deployment is automatic after code review approval.**
1. Never push directly to `main` -- branch protection will block it.
2. Push your changes to a feature branch (e.g. `feature/full-backend-v1`).
3. Open a Pull Request on Gitea targeting `main`.
4. Request review from **devops (Talal)**.
5. Once approved and merged, the staging server rebuilds and redeploys automatically.
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`.
## Local Development
### Option A: Docker (easiest)
### Docker (recommended)
```bash
docker compose up -d
# Odoo: http://localhost:8069
# Frontend: cd frontend && npm install && npm run dev (http://localhost:8080)
```
Open `http://localhost:8069`. First run: create a new database.
Create a new Odoo database on first visit, then install the `encoach_api`
module to pull in every `encoach_*` dependency.
Stop: `docker compose down`
### Source
### Option B: Source (venv + PostgreSQL)
**Prerequisites:** macOS, Homebrew, Python 3.11+, PostgreSQL 15+
Prerequisites: Python 3.12, PostgreSQL 16, Node 20, npm 10.
```bash
brew install postgresql@15 wkhtmltopdf
brew services start postgresql@15
createuser -s $(whoami)
# Backend
./setup.sh # creates venv, installs requirements.txt
./run.sh # starts Odoo on :8069
chmod +x setup.sh
./setup.sh
./run.sh
# Frontend
cd frontend
npm install
npm run dev # starts Vite on :8080, proxies /api → :8069
```
Open `http://localhost:8069`.
See [`MANUAL-RUN.md`](MANUAL-RUN.md) for a step-by-step walkthrough and
[`CONNECT-POSTGRES.md`](CONNECT-POSTGRES.md) for database wiring.
See [MANUAL-RUN.md](MANUAL-RUN.md) for step-by-step commands.
---
## Related Documents
## 2. Repository layout
```
odoo19/
├── backend/
│ └── custom_addons/
│ ├── encoach_api/ REST base + JWT + OpenAPI + metrics
│ ├── encoach_core/ Users, entities, roles, permissions
│ ├── encoach_taxonomy/ Subject → Domain → Topic
│ ├── encoach_ai/ OpenAI wrapper, cefr_mapper, validator
│ ├── encoach_ai_course/ AI course/exam generation pipelines
│ ├── encoach_ai_grading/ AI grading (writing/speaking/math/IT)
│ ├── encoach_ai_media/ TTS (Polly), STT (Whisper), ELAI
│ ├── encoach_vector/ pgvector store + RAG embeddings
│ ├── encoach_exam_template/ Canonical exam + student attempt models
│ ├── encoach_scoring/ Score computation, CEFR mapping
│ ├── encoach_quality_gate/ Automated content-quality checks
│ ├── encoach_ielts_validation/ IELTS-specific validators
│ ├── encoach_adaptive/ Adaptive engine, style matcher
│ ├── encoach_lms_api/ OpenEduCat bridge + LMS endpoints
│ ├── encoach_branding/ White-label config per entity
│ └── … (other encoach_* modules)
├── frontend/
│ ├── src/
│ │ ├── pages/ Route pages (React.lazy code-split)
│ │ ├── components/ Shared UI (shadcn/ui + custom)
│ │ ├── services/ Thin API wrappers over fetch
│ │ ├── hooks/queries/ React Query hooks + keys
│ │ ├── lib/api-client.ts Fetch + auto JWT refresh
│ │ └── types/ Shared DTO types
│ └── vite.config.ts manualChunks: react, query, charts, radix…
├── docs/
│ ├── PROJECT_SUMMARY.md Release notes & architecture history
│ ├── adr/ Architecture Decision Records
│ └── ENCOACH_*.md SRS, workflows, user stories
├── docker-compose.yml
├── Dockerfile
├── requirements.txt Python deps (pgvector, textstat, etc.)
└── README.md You are here
```
---
## 3. Architecture at a glance
```
┌────────────────────┐ HTTPS/JWT ┌────────────────────┐
│ React SPA (Vite) │ ───────────────► │ Odoo 19 + FastAPI-style │
│ - React Query │ ◄─────────────── │ controllers (encoach_*) │
│ - next-themes │ X-Request-Id └──────┬─────────────┘
│ - shadcn/ui │ │
└────────────────────┘ ▼
┌──────────────┐
│ PostgreSQL │
│ + pgvector │
└──────┬───────┘
┌──────────────┐
│ OpenAI, │
│ Whisper, │
│ Polly… │
└──────────────┘
```
Every request carries an `X-Request-Id` and emits structured JSON logs.
Prometheus-compatible counters are exposed at `/api/metrics`, and the live
OpenAPI 3.0 spec is at `/api/openapi.json`.
---
## 4. Key conventions
- **Canonical response envelope** — list endpoints return
`{ items: T[], total, page, size, data: T[] }` (see `encoach_api.controllers.base.paginated_envelope`).
- **CEFR mapping** — only `encoach_ai.services.cefr_mapper` is canonical. Do
not reintroduce local `band_to_cefr` copies.
- **JWT tokens** — short-lived access tokens (1h) + revocable refresh tokens
(7d). Only `access` tokens are accepted as Bearer credentials; refresh tokens
must go through `/api/auth/refresh`. See [`docs/adr/0002-jwt-refresh-token-flow.md`](docs/adr/0002-jwt-refresh-token-flow.md).
- **RAG metadata** — vector embeddings carry `course_id`, `subject_id`,
`entity_id`, `taxonomy`, `content_hash`. Chunking kicks in above 2 000 chars.
- **Frontend pagination** — `PaginatedResponse<T>` exposes both `items` and
`data`. Read from `items` in new code.
- **Frontend theming** — tokens live in `frontend/src/index.css` (`:root` and
`.dark`). Always use `hsl(var(--token))` instead of raw hex.
---
## 5. Health, observability, docs
| Endpoint | Purpose |
|---|---|
| `GET /api/health` | Liveness (always 200 when server is up) |
| `GET /api/health/ready` | Readiness (DB + required config) |
| `GET /api/openapi.json` | Dynamic OpenAPI 3.0 spec generated from `@http.route` |
| `GET /api/metrics` | Prometheus-format counters per route |
---
## 6. Deployment
Staging and production both use Docker Compose. The staging server rebuilds
automatically from `main`; never force-push. See
[`INSTALL-ODOO-SUMMARY.md`](INSTALL-ODOO-SUMMARY.md) for bootstrap notes.
| Service | Staging URL |
|---|---|
| Odoo backend | `http://5.189.151.117:8069` |
| React frontend | `http://5.189.151.117:3000` |
The `.env` file is **never** committed. On staging it lives at
`/opt/encoach/backend-v2/.env`.
---
## 7. Further reading
| Document | Description |
|----------|-------------|
| `ENCOACH_ODOO19_BACKEND_SRS.md` | Backend SRS v3.0 (definitive) |
| `ENCOACH_UNIFIED_SRS.md` | Unified frontend + backend SRS v2.0 |
| `ENCOACH_PRODUCT_DESCRIPTION.md` | Non-technical product description |
|---|---|
| [`docs/PROJECT_SUMMARY.md`](docs/PROJECT_SUMMARY.md) | Release notes + architecture history |
| [`docs/adr/`](docs/adr/) | Architecture Decision Records (why we built it this way) |
| [`docs/ENCOACH_UNIFIED_SRS.md`](docs/ENCOACH_UNIFIED_SRS.md) | Unified frontend + backend SRS |
| [`docs/ENCOACH_ODOO19_BACKEND_SRS.md`](docs/ENCOACH_ODOO19_BACKEND_SRS.md) | Backend SRS v3.0 |
| [`docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md`](docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md) | Backend workflows |
| [`docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md`](docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md) | Frontend workflows |
---
## 8. Contributing
1. Branch from `main` — never push direct. Branch protection enforces it.
2. Run `npx tsc --noEmit -p tsconfig.app.json` (frontend) and the module smoke
tests before opening a PR.
3. Every architectural decision should be captured as an ADR under
[`docs/adr/`](docs/adr/). Copy `0000-template.md` to start one.
4. Open the PR against `main` and request review from devops (Talal).

View File

@@ -15,7 +15,7 @@
- AI Search, Insights, Report Narrative
""",
"author": "EnCoach",
"depends": ["base", "encoach_core"],
"depends": ["base", "encoach_core", "encoach_api"],
"external_dependencies": {
"python": ["openai", "boto3"],
},

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,10 @@ import json
import logging
from odoo import http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response as _base_json_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
@@ -27,7 +31,8 @@ class CoachController(http.Controller):
return CoachService(request.env)
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_chat(self, **kw):
body = _get_json()
try:
@@ -43,7 +48,8 @@ class CoachController(http.Controller):
return _json_response({"reply": f"I'm having trouble right now. Error: {e}"})
# ── GET /api/coach/tip — AiTipBanner.tsx ──
@http.route("/api/coach/tip", type="http", auth="user", methods=["GET"], csrf=False)
@http.route("/api/coach/tip", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def coach_tip(self, **kw):
context = request.params.get("context", "general")
try:
@@ -53,7 +59,8 @@ class CoachController(http.Controller):
return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"})
# ── POST /api/coach/explain — AiGradeExplainer.tsx ──
@http.route("/api/coach/explain", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/coach/explain", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_explain(self, **kw):
body = _get_json()
try:
@@ -67,7 +74,8 @@ class CoachController(http.Controller):
return _json_response({"explanation": f"Could not generate explanation: {e}"})
# ── POST /api/coach/suggest — AiStudyCoach.tsx ──
@http.route("/api/coach/suggest", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/coach/suggest", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_suggest(self, **kw):
body = _get_json()
try:
@@ -82,7 +90,8 @@ class CoachController(http.Controller):
})
# ── POST /api/coach/writing-help — AiWritingHelper.tsx ──
@http.route("/api/coach/writing-help", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/coach/writing-help", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_writing_help(self, **kw):
body = _get_json()
try:
@@ -97,7 +106,8 @@ class CoachController(http.Controller):
return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]})
# ── POST /api/coach/hint — (unused component, wired for completeness) ──
@http.route("/api/coach/hint", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/coach/hint", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_hint(self, **kw):
body = _get_json()
try:

View File

@@ -0,0 +1,217 @@
"""Endpoints for submitting & aggregating AI feedback."""
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _feedback_to_dict(row):
return {
"id": row.id,
"subject_type": row.subject_type,
"subject_id": row.subject_id,
"subject_key": row.subject_key,
"rating": row.rating,
"comment": row.comment or "",
"tags": (row.tags or "").split(",") if row.tags else [],
"prompt_key": row.prompt_key or None,
"prompt_version": row.prompt_version or None,
"ai_log_id": row.ai_log_id.id if row.ai_log_id else None,
"user_id": row.user_id.id,
"user_name": row.user_id.name,
"entity_id": row.entity_id.id if row.entity_id else None,
"course_id": row.course_id.id if row.course_id else None,
"status": row.status,
"create_date": (
fields.Datetime.to_string(row.create_date) if row.create_date else None
),
"resolved_at": (
fields.Datetime.to_string(row.resolved_at) if row.resolved_at else None
),
"resolution_notes": row.resolution_notes or "",
}
class EncoachAIFeedbackController(http.Controller):
"""Feedback write + admin triage endpoints."""
# ------------------------------------------------------------------
# POST /api/ai/feedback — student submits/updates feedback
#
# Upsert semantics: we enforce uniqueness per (user, subject_type,
# subject_id) at the DB layer, so the client doesn't need to know whether
# the row already exists.
# ------------------------------------------------------------------
@http.route("/api/ai/feedback", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def submit(self, **kw):
try:
body = _get_json_body() or {}
subject_type = body.get("subject_type")
subject_id = body.get("subject_id")
rating = body.get("rating")
if subject_type not in {
"question", "coach", "explanation", "translation", "narrative", "other",
}:
return _error_response("Invalid subject_type", 400)
if not isinstance(subject_id, int) or subject_id <= 0:
return _error_response("subject_id must be a positive integer", 400)
if rating not in {"up", "down"}:
return _error_response("rating must be 'up' or 'down'", 400)
comment = body.get("comment") or ""
if rating == "down" and not (comment or "").strip():
# We keep this a soft constraint so the UI can decide how
# strict to be; returning an error here gives clients a clear
# path to prompt the user when needed.
return _error_response(
"A short comment is required when rating is 'down'.", 400,
)
vals = {
"subject_type": subject_type,
"subject_id": subject_id,
"rating": rating,
"comment": comment,
"tags": (
",".join(body.get("tags") or [])
if isinstance(body.get("tags"), list)
else (body.get("tags") or "")
),
"prompt_key": body.get("prompt_key") or False,
"prompt_version": body.get("prompt_version") or 0,
"ai_log_id": body.get("ai_log_id") or False,
"entity_id": body.get("entity_id") or False,
"course_id": body.get("course_id") or False,
"user_id": request.env.user.id,
}
Feedback = request.env["encoach.ai.feedback"].sudo()
existing = Feedback.search([
("user_id", "=", request.env.user.id),
("subject_type", "=", subject_type),
("subject_id", "=", subject_id),
], limit=1)
with request.env.cr.savepoint():
if existing:
existing.write(vals)
row = existing
else:
row = Feedback.create(vals)
return _json_response(_feedback_to_dict(row), 200 if existing else 201)
except Exception as e:
_logger.exception("ai feedback submit failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/feedback/summary?subject_type=question&subject_id=42
# ------------------------------------------------------------------
@http.route(
"/api/ai/feedback/summary", type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def summary(self, subject_type=None, subject_id=None, **kw):
try:
if not subject_type or not subject_id:
return _error_response("subject_type and subject_id are required", 400)
Feedback = request.env["encoach.ai.feedback"].sudo()
domain = [
("subject_type", "=", subject_type),
("subject_id", "=", int(subject_id)),
]
rows = Feedback.search(domain)
up = sum(1 for r in rows if r.rating == "up")
down = sum(1 for r in rows if r.rating == "down")
my_row = Feedback.search([
*domain,
("user_id", "=", request.env.user.id),
], limit=1)
return _json_response({
"subject_type": subject_type,
"subject_id": int(subject_id),
"up": up,
"down": down,
"total": up + down,
"my_rating": my_row.rating if my_row else None,
"my_comment": my_row.comment or "" if my_row else "",
})
except Exception as e:
_logger.exception("ai feedback summary failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/feedback — admin triage list
# ------------------------------------------------------------------
@http.route("/api/ai/feedback", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_feedback(self, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
Feedback = request.env["encoach.ai.feedback"].sudo()
domain = []
if kw.get("status"):
domain.append(("status", "=", kw["status"]))
if kw.get("rating"):
domain.append(("rating", "=", kw["rating"]))
if kw.get("subject_type"):
domain.append(("subject_type", "=", kw["subject_type"]))
if kw.get("prompt_key"):
domain.append(("prompt_key", "=", kw["prompt_key"]))
offset, limit, page = _paginate(kw)
total = Feedback.search_count(domain)
rows = Feedback.search(domain, offset=offset, limit=limit, order="create_date desc")
items = [_feedback_to_dict(r) for r in rows]
return _json_response({
"items": items,
"data": items,
"total": total,
"page": page,
"size": limit,
})
except Exception as e:
_logger.exception("ai feedback list failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/feedback/<id>/resolve — admin triage
# ------------------------------------------------------------------
@http.route(
'/api/ai/feedback/<int:feedback_id>/resolve',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def resolve(self, feedback_id, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
body = _get_json_body() or {}
status = body.get("status")
if status not in {"acknowledged", "fixed", "dismissed"}:
return _error_response(
"status must be one of acknowledged|fixed|dismissed", 400,
)
row = request.env["encoach.ai.feedback"].sudo().browse(feedback_id)
if not row.exists():
return _error_response("Feedback not found", 404)
with request.env.cr.savepoint():
row.write({
"status": status,
"resolved_by_id": request.env.user.id,
"resolved_at": fields.Datetime.now(),
"resolution_notes": body.get("notes") or "",
})
return _json_response(_feedback_to_dict(row))
except Exception as e:
_logger.exception("ai feedback resolve failed")
return _error_response(str(e), 500)

View File

@@ -5,6 +5,10 @@ import json
import logging
from odoo import http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response as _base_json_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
@@ -52,7 +56,8 @@ class MediaController(http.Controller):
)
# ── POST /api/exam/listening/media — generate listening audio ──
@http.route("/api/exam/listening/media", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/exam/listening/media", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def listening_media(self, **kw):
body = _get_json()
text = body.get("text", "")
@@ -72,7 +77,8 @@ class MediaController(http.Controller):
return _json_response({"error": str(e)}, 500)
# ── POST /api/exam/speaking/media — generate speaking prompt audio ──
@http.route("/api/exam/speaking/media", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/exam/speaking/media", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def speaking_media(self, **kw):
body = _get_json()
text = body.get("text", "")
@@ -89,7 +95,8 @@ class MediaController(http.Controller):
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatars — list ELAI avatars ──
@http.route("/api/exam/avatars", type="http", auth="user", methods=["GET"], csrf=False)
@http.route("/api/exam/avatars", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_avatars(self, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
@@ -100,7 +107,8 @@ class MediaController(http.Controller):
return _json_response({"avatars": [], "note": str(e)})
# ── POST /api/exam/avatar/video — create avatar video ──
@http.route("/api/exam/avatar/video", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/exam/avatar/video", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def create_avatar_video(self, **kw):
body = _get_json()
try:
@@ -116,7 +124,8 @@ class MediaController(http.Controller):
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatar/video/:id — check video status ──
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="user", 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="user", methods=["POST"], csrf=False)
@http.route("/api/courses/ai-generate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_generate_course(self, **kw):
body = _get_json()
try:

View File

@@ -0,0 +1,219 @@
"""Admin endpoints for the versioned AI prompt editor."""
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _prompt_to_dict(prompt, *, include_content=True):
data = {
"id": prompt.id,
"key": prompt.key,
"version": prompt.version,
"title": prompt.title or "",
"description": prompt.description or "",
"is_active": bool(prompt.is_active),
"variables": (prompt.variables or "").split(",") if prompt.variables else [],
"author_id": prompt.author_id.id if prompt.author_id else None,
"author_name": prompt.author_id.name if prompt.author_id else "",
"activated_at": (
fields.Datetime.to_string(prompt.activated_at) if prompt.activated_at else None
),
"create_date": (
fields.Datetime.to_string(prompt.create_date) if prompt.create_date else None
),
}
if include_content:
data["content"] = prompt.content or ""
return data
class EncoachAIPromptController(http.Controller):
"""Versioned prompt CRUD.
Auth: every endpoint requires a valid JWT. The write endpoints additionally
check for admin rights via ``res.users.has_group('base.group_system')`` so
non-admins can read prompts but not mutate them.
"""
# ------------------------------------------------------------------
# GET /api/ai/prompts — list keys (latest version per key)
# ------------------------------------------------------------------
@http.route("/api/ai/prompts", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_keys(self, **kw):
try:
Prompt = request.env["encoach.ai.prompt"].sudo()
# One row per key = the latest version. We fetch candidates then
# deduplicate in Python because Odoo's ORM doesn't expose DISTINCT
# ON ergonomically.
search = (kw.get("search") or "").strip()
domain = [("key", "ilike", search)] if search else []
# Sort by key then descending version so the first occurrence per
# key is the latest.
all_prompts = Prompt.search(domain, order="key asc, version desc")
seen = set()
latest = []
for p in all_prompts:
if p.key in seen:
continue
seen.add(p.key)
latest.append(p)
offset, per_page, page = _paginate(kw)
total = len(latest)
window = latest[offset : offset + per_page]
items = [_prompt_to_dict(p, include_content=False) for p in window]
# Attach version-count for the UI to show "v5 (of 7)" hints.
counts = {}
for p in all_prompts:
counts[p.key] = counts.get(p.key, 0) + 1
for entry in items:
entry["total_versions"] = counts.get(entry["key"], 1)
return _json_response({
"items": items,
"data": items,
"total": total,
"page": page,
"size": per_page,
})
except Exception as e:
_logger.exception("list ai prompts failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/prompts/<key>/versions
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<string:key>/versions',
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def list_versions(self, key, **kw):
try:
Prompt = request.env["encoach.ai.prompt"].sudo()
prompts = Prompt.search([("key", "=", key)], order="version desc")
if not prompts:
return _error_response("Prompt key not found", 404)
items = [_prompt_to_dict(p, include_content=False) for p in prompts]
return _json_response({
"key": key,
"items": items,
"data": items,
"total": len(items),
"page": 1,
"size": len(items),
})
except Exception as e:
_logger.exception("list prompt versions failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/prompts/<int:prompt_id>
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>',
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def get_version(self, prompt_id, **kw):
try:
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
return _json_response(_prompt_to_dict(prompt))
except Exception as e:
_logger.exception("get prompt failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts — create a new version of a key (or a new key)
# ------------------------------------------------------------------
@http.route("/api/ai/prompts", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def create_version(self, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
body = _get_json_body() or {}
key = (body.get("key") or "").strip()
content = body.get("content") or ""
title = (body.get("title") or "").strip()
if not key or not content.strip() or not title:
return _error_response(
"key, title, and content are required", 400,
)
vals = {
"key": key,
"title": title,
"description": body.get("description") or "",
"content": content,
"is_active": bool(body.get("activate", False)),
}
with request.env.cr.savepoint():
prompt = request.env["encoach.ai.prompt"].sudo().create(vals)
return _json_response(_prompt_to_dict(prompt), 201)
except Exception as e:
_logger.exception("create prompt version failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts/<id>/activate
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/activate',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def activate(self, prompt_id, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
with request.env.cr.savepoint():
prompt.write({"is_active": True})
return _json_response(_prompt_to_dict(prompt))
except Exception as e:
_logger.exception("activate prompt failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts/<id>/render — dry-run render with sample variables
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/render',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def render_preview(self, prompt_id, **kw):
try:
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
body = _get_json_body() or {}
variables = body.get("variables") or {}
if not isinstance(variables, dict):
return _error_response("variables must be a JSON object", 400)
rendered = prompt.render(variables)
return _json_response({
"rendered": rendered,
"key": prompt.key,
"version": prompt.version,
})
except Exception as e:
_logger.exception("render prompt failed")
return _error_response(str(e), 400)

View File

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

View File

@@ -0,0 +1,134 @@
"""Student feedback on AI-generated content (thumbs up/down).
This closes the AI quality loop started by the human-review workflow (P3.3)
and the versioned prompts (P3.4):
* Every AI-generated artefact (a question, a coach reply, an explanation, a
translation, ...) can have any number of feedback rows attached to it.
* Feedback is always coupled to a **subject type + subject id** so it can
point at anything (``encoach.question``, ``encoach.ai.log``, ...) without
forcing a hard FK per model.
* When the student flags feedback as negative, we also store their free-text
note so product can triage.
Downstream consumers (prompt library, RAG indexers, report dashboards) can
read ``encoach.ai.feedback`` to compute win rates per prompt key, per model,
per entity, etc.
"""
from __future__ import annotations
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class EncoachAIFeedback(models.Model):
_name = "encoach.ai.feedback"
_description = "Student feedback on AI-generated content"
_order = "create_date desc"
_rec_name = "subject_key"
# ------------------------------------------------------------------
# What was rated
# ------------------------------------------------------------------
subject_type = fields.Selection(
[
("question", "Exam question"),
("coach", "AI Coach reply"),
("explanation", "Content explanation"),
("translation", "Translation"),
("narrative", "Report narrative"),
("other", "Other AI output"),
],
required=True, index=True,
)
subject_id = fields.Integer(
required=True, index=True,
help="Id of the rated artefact (semantics depend on subject_type).",
)
subject_key = fields.Char(
compute="_compute_subject_key", store=True, index=True,
help="Denormalised '<type>:<id>' key for quick lookups/aggregations.",
)
# Optional link to the AI call that produced this artefact. When set, it
# lets us correlate feedback with the underlying model/prompt used.
ai_log_id = fields.Many2one(
"encoach.ai.log", ondelete="set null", index=True,
)
prompt_key = fields.Char(
index=True,
help="If the artefact was produced via encoach.ai.prompt, record the "
"key here so we can compute win-rates per prompt.",
)
prompt_version = fields.Integer()
# ------------------------------------------------------------------
# Rating
# ------------------------------------------------------------------
rating = fields.Selection(
[("up", "Thumbs up"), ("down", "Thumbs down")],
required=True, index=True,
)
comment = fields.Text(
help="Free-text note. Required when rating is 'down' and the UI "
"prompts the student for a reason.",
)
tags = fields.Char(
help="Comma-separated tags (e.g. 'wrong-answer,confusing-stem').",
)
# ------------------------------------------------------------------
# Who / context
# ------------------------------------------------------------------
user_id = fields.Many2one(
"res.users", required=True, index=True,
default=lambda self: self.env.user,
)
entity_id = fields.Many2one(
"encoach.entity", ondelete="set null", index=True,
)
course_id = fields.Many2one(
"encoach.course", ondelete="set null", index=True,
)
# ------------------------------------------------------------------
# Triage status (filled in by admins/coaches reviewing feedback).
# ------------------------------------------------------------------
status = fields.Selection(
[
("open", "Open"),
("acknowledged", "Acknowledged"),
("fixed", "Fixed"),
("dismissed", "Dismissed"),
],
default="open", index=True,
)
resolved_by_id = fields.Many2one(
"res.users", ondelete="set null",
)
resolved_at = fields.Datetime()
resolution_notes = fields.Text()
# ------------------------------------------------------------------
# Constraints
# ------------------------------------------------------------------
_sql_constraints = [
(
"uniq_user_subject",
"unique(user_id, subject_type, subject_id)",
"A user can only leave one feedback row per AI artefact. "
"Subsequent submissions should update the existing row.",
),
]
@api.depends("subject_type", "subject_id")
def _compute_subject_key(self):
for rec in self:
rec.subject_key = f"{rec.subject_type or ''}:{rec.subject_id or 0}"
@api.constrains("subject_type", "subject_id")
def _check_subject(self):
for rec in self:
if not rec.subject_type or not rec.subject_id:
raise ValidationError("subject_type and subject_id are required.")

View File

@@ -0,0 +1,213 @@
"""Versioned AI prompt templates.
Context (ADR-0004, P3.4 hardening release): we want non-engineers to iterate
on LLM prompts without shipping code. Every time an author edits a prompt we
keep the previous revision around so we can A/B test, audit, and roll back.
Design:
* A **prompt** is identified by its ``key`` (e.g. ``exam.mcq.generate``) — a
stable, dotted string that callers reference from Python.
* Each save produces a new **version** row (bumped ``version`` int). The old
rows stay with ``is_active = False`` so history is preserved; only one row
per ``key`` can be active at a time (enforced in ``write``/``create``).
* Callers look the prompt up with ``get_active(key)`` and render it with
``render(variables)``. Rendering uses :py:meth:`str.format_map` so templates
are just ``{variable}`` placeholders — no new DSL to learn.
This module intentionally does **not** call the LLM itself. It is a
content-management layer that the existing ``encoach_ai.services.*`` pipelines
can adopt at their own pace (falling back to their hard-coded strings until a
prompt with the matching key is created in the UI).
"""
from __future__ import annotations
import logging
import re
from typing import Iterable
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
# Keys look like ``namespace.sub_namespace.name`` — lowercase, dotted.
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
# Variables pulled from ``{foo}`` / ``{foo.bar}`` placeholders.
_VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}")
class EncoachAIPrompt(models.Model):
_name = "encoach.ai.prompt"
_description = "Versioned AI prompt template"
_order = "key, version desc"
_rec_name = "display_name"
key = fields.Char(
required=True,
index=True,
help="Stable dotted identifier (e.g. 'exam.mcq.generate'). "
"Callers reference this from Python.",
)
version = fields.Integer(required=True, default=1, index=True)
title = fields.Char(
required=True,
help="Short human label shown in the admin editor.",
)
description = fields.Text(
help="Author intent, expected variables, known limitations.",
)
content = fields.Text(
required=True,
help="Template body. Use {variable} placeholders; "
"render() fills them via str.format_map.",
)
is_active = fields.Boolean(
default=False, index=True,
help="Exactly one active row per key. Creating/activating a new row "
"automatically deactivates the previous active version.",
)
author_id = fields.Many2one("res.users", ondelete="set null", readonly=True)
activated_at = fields.Datetime(readonly=True)
# Denormalised for quick display/search. Recomputed on save.
variables = fields.Char(
compute="_compute_variables", store=True,
help="Comma-separated list of {variable} placeholders found in content.",
)
display_name = fields.Char(
compute="_compute_display_name", store=True,
)
_sql_constraints = [
(
"key_version_uniq",
"unique(key, version)",
"A prompt version must be unique for its key.",
),
]
# ------------------------------------------------------------------
# Computed fields
# ------------------------------------------------------------------
@api.depends("content")
def _compute_variables(self):
for rec in self:
if not rec.content:
rec.variables = ""
continue
found = sorted(set(_VAR_RE.findall(rec.content)))
rec.variables = ",".join(found)
@api.depends("key", "version", "title")
def _compute_display_name(self):
for rec in self:
rec.display_name = f"{rec.key} v{rec.version}{rec.title or ''}".rstrip("")
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
@api.constrains("key")
def _check_key_shape(self):
for rec in self:
if not rec.key or not _KEY_RE.match(rec.key):
raise ValidationError(
f"Invalid prompt key {rec.key!r}. "
"Use lowercase dotted identifiers like 'exam.mcq.generate'."
)
@api.constrains("content")
def _check_content_non_empty(self):
for rec in self:
if not (rec.content or "").strip():
raise ValidationError("Prompt content cannot be empty.")
# ------------------------------------------------------------------
# Create / write — enforce "one active per key"
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
# Default version = max(existing) + 1 when caller omits it.
for vals in vals_list:
if "version" not in vals or not vals.get("version"):
key = vals.get("key")
existing = self.search(
[("key", "=", key)], order="version desc", limit=1,
)
vals["version"] = (existing.version + 1) if existing else 1
vals.setdefault("author_id", self.env.user.id)
if vals.get("is_active"):
vals.setdefault("activated_at", fields.Datetime.now())
records = super().create(vals_list)
# Post-create: enforce exclusivity for any rows created as active.
for rec in records:
if rec.is_active:
rec._deactivate_siblings()
return records
def write(self, vals):
res = super().write(vals)
if vals.get("is_active"):
now = fields.Datetime.now()
for rec in self:
if rec.is_active and not rec.activated_at:
super(EncoachAIPrompt, rec).write({"activated_at": now})
rec._deactivate_siblings()
return res
def _deactivate_siblings(self):
for rec in self:
if not rec.is_active:
continue
siblings = self.search([
("key", "=", rec.key),
("id", "!=", rec.id),
("is_active", "=", True),
])
if siblings:
siblings.write({"is_active": False})
# ------------------------------------------------------------------
# Public API (called by pipelines / controllers)
# ------------------------------------------------------------------
@api.model
def get_active(self, key: str):
"""Return the active prompt record for ``key`` (or an empty set)."""
return self.sudo().search(
[("key", "=", key), ("is_active", "=", True)], limit=1,
)
def render(self, variables: dict | None = None) -> str:
"""Fill ``{placeholders}`` using ``variables`` (dict)."""
self.ensure_one()
variables = variables or {}
missing = self._missing_variables(variables.keys())
if missing:
raise UserError(
f"Missing prompt variables for {self.key} v{self.version}: "
f"{', '.join(sorted(missing))}"
)
try:
return self.content.format_map(_SafeFormatDict(variables))
except Exception as exc:
_logger.exception("prompt render failed for %s v%s", self.key, self.version)
raise UserError(f"Prompt render failed: {exc}") from exc
def _missing_variables(self, provided: Iterable[str]) -> set[str]:
self.ensure_one()
required = set(_VAR_RE.findall(self.content or ""))
provided_root = {p.split(".")[0] for p in provided}
return {v for v in required if v.split(".")[0] not in provided_root}
class _SafeFormatDict(dict):
"""Dict that returns ``''`` for dotted/nested lookups str.format can't resolve.
We don't advertise ``{foo.bar}`` as supported (the editor shows top-level
variables only), but we also don't want rendering to explode if a caller
passes an object with attributes.
"""
def __missing__(self, key):
return "{" + key + "}"

View File

@@ -0,0 +1,13 @@
CEFR_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
GPT_MODELS = {
'default': 'gpt-4o',
'fast': 'gpt-4o-mini',
}
TEMPERATURE = 0.7
TOPICS = [
'Education', 'Technology', 'Health', 'Environment', 'Culture',
'Travel', 'Science', 'Business', 'Sports', 'Society',
]

View File

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

View File

@@ -5,3 +5,5 @@ from .elevenlabs_service import ElevenLabsService
from .gptzero_service import GPTZeroService
from .elai_service import ElaiService
from .coach_service import CoachService
from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9)
from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1)

View File

@@ -0,0 +1,149 @@
"""Canonical CEFR / IELTS band / IRT theta mapper.
This is the SINGLE SOURCE OF TRUTH for band ↔ CEFR ↔ theta conversions across
the platform. All modules MUST import from here rather than hand-rolling their
own mapping tables. See §21 of docs/PROJECT_SUMMARY.md for the rationale.
"""
# IRT theta band thresholds (ability parameter → CEFR level)
THETA_TO_CEFR = [
(-4.0, -2.5, 'pre_a1'),
(-2.5, -1.5, 'a1'),
(-1.5, -0.5, 'a2'),
(-0.5, 0.5, 'b1'),
(0.5, 1.5, 'b2'),
(1.5, 2.5, 'c1'),
(2.5, 4.0, 'c2'),
]
# CEFR code → canonical IELTS band centre value
CEFR_TO_BAND = {
'pre_a1': 2.0,
'a1': 3.0,
'a2': 4.0,
'b1': 5.0,
'b2': 6.5,
'c1': 7.5,
'c2': 9.0,
}
# Lowercase CEFR code → human-readable label
CEFR_LABELS = {
'pre_a1': 'Pre-A1 (Beginner)',
'a1': 'A1 (Elementary)',
'a2': 'A2 (Pre-Intermediate)',
'b1': 'B1 (Intermediate)',
'b2': 'B2 (Upper-Intermediate)',
'c1': 'C1 (Advanced)',
'c2': 'C2 (Proficient)',
}
# Monotonic (highest band → lowest) thresholds used by band_to_cefr.
# Picked to align CEFR_TO_BAND with widely-published IELTS/CEFR crosswalks
# (Cambridge, Council of Europe): 8.0+ → C2, 7.0-7.9 → C1, 5.5-6.9 → B2, etc.
_BAND_THRESHOLDS = [
(8.0, 'c2'),
(7.0, 'c1'),
(5.5, 'b2'),
(4.5, 'b1'),
(3.5, 'a2'),
(2.5, 'a1'),
(0.0, 'pre_a1'),
]
def band_to_cefr(band):
"""Map an IELTS band (0-9, may be float) to a canonical CEFR code."""
if band is None:
return None
try:
b = float(band)
except (TypeError, ValueError):
return None
for threshold, level in _BAND_THRESHOLDS:
if b >= threshold:
return level
return 'pre_a1'
def theta_to_cefr(theta):
"""Map an IRT theta ability parameter (typically -4..+4) to a CEFR code."""
try:
t = float(theta)
except (TypeError, ValueError):
return 'pre_a1'
for low, high, level in THETA_TO_CEFR:
if low <= t < high:
return level
return 'c2' if t >= 2.5 else 'pre_a1'
def theta_to_band(theta):
"""Interpolate an IRT theta to an IELTS band, rounded to nearest 0.5."""
try:
t = float(theta)
except (TypeError, ValueError):
return 0.0
cefr = theta_to_cefr(t)
base_band = CEFR_TO_BAND.get(cefr, 5.0)
for low, high, level in THETA_TO_CEFR:
if level == cefr:
width = high - low
position = (t - low) / width if width > 0 else 0.5
cefr_list = list(CEFR_TO_BAND.keys())
idx = cefr_list.index(cefr)
next_band = CEFR_TO_BAND.get(
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
)
band = base_band + position * (next_band - base_band)
return round(band * 2) / 2
return base_band
def cefr_to_band(cefr):
"""Return the canonical centre-of-range band for a CEFR code."""
if not cefr:
return None
return CEFR_TO_BAND.get(str(cefr).strip().lower().replace('-', '_'))
def normalize_cefr(label):
"""Canonicalise a CEFR label to the uppercase display form (e.g. 'A2', 'Pre-A1').
Accepts 'a2', 'A2', 'pre_a1', 'Pre-A1', 'pre a1' etc. Returns ``None``
if the input is falsy; returns the input unchanged if unrecognised.
"""
if not label:
return None
s = str(label).strip().lower().replace('-', '_').replace(' ', '_')
return {
'pre_a1': 'Pre-A1',
'a1': 'A1',
'a2': 'A2',
'b1': 'B1',
'b2': 'B2',
'c1': 'C1',
'c2': 'C2',
}.get(s, label)
def cefr_label(cefr_code):
"""Return the human-readable label (e.g. 'B2 (Upper-Intermediate)') for a code."""
return CEFR_LABELS.get(cefr_code, cefr_code)
class CefrMapper:
"""Class-style facade kept for backward compatibility with ``encoach_placement``."""
THETA_TO_CEFR = THETA_TO_CEFR
CEFR_TO_BAND = CEFR_TO_BAND
CEFR_LABELS = CEFR_LABELS
theta_to_cefr = staticmethod(theta_to_cefr)
theta_to_band = staticmethod(theta_to_band)
band_to_cefr = staticmethod(band_to_cefr)
@staticmethod
def get_cefr_label(cefr_code):
return cefr_label(cefr_code)

View File

@@ -20,16 +20,20 @@ class OpenAIService:
self._get_param = env["ir.config_parameter"].sudo().get_param
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
self.max_retries = int(self._get_param("encoach_ai.max_retries", "3"))
# Hard wall-clock timeout (seconds) enforced by the OpenAI SDK on each
# HTTP call. Prevents a single slow LLM request from tying up an Odoo
# worker for minutes. See P0.5 in docs/PROJECT_SUMMARY.md §21.
self.request_timeout = float(self._get_param("encoach_ai.request_timeout", "30"))
api_key = self._get_param("encoach_ai.openai_api_key", "")
if not api_key:
import os
api_key = os.environ.get("OPENAI_API_KEY", "")
if _openai_mod and api_key:
self.client = _openai_mod.OpenAI(api_key=api_key)
self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout)
else:
self.client = None
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo")
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini")
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
try:
@@ -86,6 +90,7 @@ class OpenAIService:
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=self.request_timeout,
)
resp = self._retry_with_backoff(_call, action, model)
content = resp.choices[0].message.content
@@ -112,6 +117,7 @@ class OpenAIService:
temperature=temperature,
max_tokens=max_tokens,
response_format={"type": "json_object"},
timeout=self.request_timeout,
)
resp = self._retry_with_backoff(_call, action, model)
raw = resp.choices[0].message.content
@@ -341,3 +347,6 @@ class OpenAIService:
messages.append({"role": "user", "content": brief_text})
return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096)
EncoachOpenAIService = OpenAIService

View File

@@ -0,0 +1,318 @@
"""Validation + quality gate for AI-generated exam content.
This module is the single entry point used by exam-generation controllers to:
1. **Schema-validate** raw LLM output before any DB insert (``validate_payload``),
catching malformed AI responses (missing prompt, empty options for MCQ,
wrong correct_answer format, etc.) before they corrupt the question pool.
2. **Score content quality** of persisted questions (``score_question``), wrapping
:class:`encoach_quality_gate.services.quality_checker.QualityChecker` and
:class:`encoach_ielts_validation.services.validator.IeltsValidator` with
graceful degradation when those addons are not installed or their optional
Python deps (``textstat``, ``language_tool_python``) are missing.
Design choices:
- Zero hard dependency on ``jsonschema`` or ``pydantic`` — the schema is a
lightweight, introspectable dict used by :func:`_validate_dict`. Good enough
for the shapes we actually emit; keeps our external-deps footprint small.
- Quality scoring is best-effort and **never** raises to callers — failures
degrade to a ``warn`` verdict so the HTTP request still completes.
"""
from __future__ import annotations
import hashlib
import json
import logging
from typing import Any
_logger = logging.getLogger(__name__)
VERDICT_OK = "ok"
VERDICT_WARN = "warn"
VERDICT_FAIL = "fail"
MIN_STEM_LEN = 8
MIN_QUALITY_SCORE = 0.55
_VALID_QUESTION_TYPES = {
"mcq", "mcq_multi", "tfng", "ynng", "gap_fill", "short_answer",
"form_completion", "note_completion", "map_labelling", "matching",
"summary_completion", "heading_matching", "matching_features",
"numerical", "expression", "code_completion", "code_output",
"sql_query", "matrix", "graph",
}
_SKILL_VALUES = {
"listening", "reading", "writing", "speaking", "grammar",
"vocabulary", "math", "it",
}
EXERCISE_SCHEMA: dict[str, dict[str, Any]] = {
"prompt": {"type": str, "required": True, "min_len": MIN_STEM_LEN},
"type": {"type": str, "required": False, "choices": _VALID_QUESTION_TYPES | {
"multiple_choice", "true_false", "fill_blanks", "matching_headings",
"paragraph_match", "sentence_completion", "matching_information",
}},
"options": {"type": list, "required": False},
"correct_answer": {"type": (str, list, int, float), "required": False},
"marks": {"type": (int, float), "required": False, "min": 0, "max": 20},
"cefr_level": {"type": str, "required": False},
}
MODULE_SCHEMA: dict[str, dict[str, Any]] = {
"difficulty": {"type": (list, str), "required": False},
"timer": {"type": (int, float), "required": False, "min": 0, "max": 600},
"totalMarks": {"type": (int, float), "required": False, "min": 0, "max": 500},
"passages": {"type": list, "required": False},
"sections": {"type": list, "required": False},
"tasks": {"type": list, "required": False},
"parts": {"type": list, "required": False},
}
def _validate_dict(obj: Any, schema: dict, path: str = "") -> list[str]:
"""Return list of human-readable validation errors for ``obj`` against ``schema``."""
errors: list[str] = []
if not isinstance(obj, dict):
errors.append(f"{path or 'root'}: expected object, got {type(obj).__name__}")
return errors
for key, rule in schema.items():
here = f"{path}.{key}" if path else key
if key not in obj or obj[key] in (None, ""):
if rule.get("required"):
errors.append(f"{here}: missing required field")
continue
val = obj[key]
expected_type = rule.get("type")
if expected_type and not isinstance(val, expected_type):
got = type(val).__name__
want = (
expected_type.__name__ if isinstance(expected_type, type)
else "/".join(t.__name__ for t in expected_type)
)
errors.append(f"{here}: expected {want}, got {got}")
continue
if isinstance(val, str):
min_len = rule.get("min_len")
if min_len and len(val.strip()) < min_len:
errors.append(f"{here}: too short (< {min_len} chars)")
choices = rule.get("choices")
if choices and val not in choices:
errors.append(f"{here}: '{val}' not in allowed values")
if isinstance(val, (int, float)):
if "min" in rule and val < rule["min"]:
errors.append(f"{here}: {val} < min {rule['min']}")
if "max" in rule and val > rule["max"]:
errors.append(f"{here}: {val} > max {rule['max']}")
return errors
def _validate_exercise(ex: Any, path: str = "exercise") -> list[str]:
"""Validate a single AI-generated exercise dict."""
errors = _validate_dict(ex, EXERCISE_SCHEMA, path)
if errors:
return errors
q_type = (ex.get("type") or "mcq").lower()
options = ex.get("options") or []
correct = ex.get("correct_answer")
if q_type in ("mcq", "multiple_choice"):
if not options or len(options) < 2:
errors.append(f"{path}.options: MCQ requires >= 2 options")
if correct in (None, ""):
errors.append(f"{path}.correct_answer: MCQ requires a correct_answer")
if q_type in ("mcq_multi",):
if not isinstance(correct, list) or len(correct) < 1:
errors.append(f"{path}.correct_answer: mcq_multi requires a list of answers")
if q_type in ("true_false", "tfng", "ynng"):
if isinstance(correct, str) and correct.lower() not in {
"true", "false", "not_given", "yes", "no", "t", "f"
}:
errors.append(f"{path}.correct_answer: '{correct}' not a valid T/F/NG answer")
return errors
def validate_payload(payload: Any) -> dict[str, Any]:
"""Validate an entire exam-generation submit payload.
Expected shape (best-effort, since the frontend already normalizes it):
.. code-block:: text
{
"title": str (required),
"modules": {
"<skill>": {
"passages": [{"text": str, "exercises": [ExerciseSchema, ...]}],
"sections": [...], "tasks": [...], "parts": [...],
...
},
...
}
}
Returns a dict with::
{"verdict": "ok"|"warn"|"fail", "errors": [...], "warnings": [...]}
``verdict == "fail"`` means the caller MUST NOT persist this payload as-is.
"""
errors: list[str] = []
warnings: list[str] = []
if not isinstance(payload, dict):
return {"verdict": VERDICT_FAIL,
"errors": [f"payload: expected object, got {type(payload).__name__}"],
"warnings": []}
title = (payload.get("title") or "").strip()
if not title:
errors.append("title: missing required field")
modules = payload.get("modules") or {}
if not isinstance(modules, dict) or not modules:
errors.append("modules: missing or empty — nothing to persist")
return {"verdict": VERDICT_FAIL, "errors": errors, "warnings": warnings}
total_exercises = 0
for mod_key, mod in modules.items():
mpath = f"modules.{mod_key}"
if not isinstance(mod, dict):
errors.append(f"{mpath}: expected object")
continue
errors.extend(_validate_dict(mod, MODULE_SCHEMA, mpath))
for p_idx, passage in enumerate(mod.get("passages") or []):
if not isinstance(passage, dict):
errors.append(f"{mpath}.passages[{p_idx}]: expected object")
continue
for e_idx, ex in enumerate(passage.get("exercises") or []):
total_exercises += 1
errors.extend(_validate_exercise(
ex, f"{mpath}.passages[{p_idx}].exercises[{e_idx}]"))
for s_idx, sec in enumerate(mod.get("sections") or []):
if not isinstance(sec, dict):
errors.append(f"{mpath}.sections[{s_idx}]: expected object")
continue
for e_idx, ex in enumerate(sec.get("exercises") or []):
total_exercises += 1
errors.extend(_validate_exercise(
ex, f"{mpath}.sections[{s_idx}].exercises[{e_idx}]"))
if total_exercises == 0 and not any(
(m.get("tasks") or m.get("parts")) for m in modules.values() if isinstance(m, dict)
):
warnings.append("no exercises/tasks/parts found — exam will have no questions")
if errors:
verdict = VERDICT_FAIL
elif warnings:
verdict = VERDICT_WARN
else:
verdict = VERDICT_OK
return {"verdict": verdict, "errors": errors, "warnings": warnings,
"total_exercises": total_exercises}
def prompt_hash(prompt: str) -> str:
"""SHA-256 hex digest of a rendered prompt, for provenance tracking."""
return hashlib.sha256((prompt or "").encode("utf-8")).hexdigest()
def score_question(stem: str, *, skill: str | None = None,
cefr_level: str | None = None) -> dict[str, Any]:
"""Best-effort quality score for a persisted question stem.
Wraps QualityChecker + IeltsValidator defensively — any import/runtime
failure downgrades the verdict to ``warn`` without raising. Returns::
{
"verdict": "ok"|"warn"|"fail",
"score": float in [0, 1],
"readability": {...} | None,
"grammar_issues": int,
"checks_ran": ["readability", "grammar", ...],
"errors": [str, ...],
}
"""
report: dict[str, Any] = {
"verdict": VERDICT_OK,
"score": 1.0,
"readability": None,
"grammar_issues": 0,
"checks_ran": [],
"errors": [],
}
if not stem or len(stem.strip()) < MIN_STEM_LEN:
report["verdict"] = VERDICT_FAIL
report["score"] = 0.0
report["errors"].append(f"stem shorter than {MIN_STEM_LEN} chars")
return report
try:
from odoo.addons.encoach_quality_gate.services.quality_checker import QualityChecker
except Exception as exc:
report["verdict"] = VERDICT_WARN
report["errors"].append(f"quality_gate unavailable: {exc}")
return report
try:
readability = QualityChecker.check_readability(stem)
report["readability"] = readability
report["checks_ran"].append("readability")
if cefr_level:
cefr_match = QualityChecker.check_cefr_alignment(stem, cefr_level.lower())
report["cefr_alignment"] = cefr_match
report["checks_ran"].append("cefr_alignment")
if not cefr_match.get("aligned", True):
report["score"] -= 0.2
grammar = QualityChecker.check_grammar(stem)
report["grammar_issues"] = len(grammar.get("issues", [])) if isinstance(grammar, dict) else 0
report["checks_ran"].append("grammar")
if report["grammar_issues"] > 3:
report["score"] -= 0.2
except Exception as exc:
_logger.warning("QualityChecker failed for stem (skill=%s): %s", skill, exc)
report["verdict"] = VERDICT_WARN
report["errors"].append(f"quality_checker error: {exc}")
report["score"] = max(0.0, min(1.0, report["score"]))
if report["verdict"] == VERDICT_OK and report["score"] < MIN_QUALITY_SCORE:
report["verdict"] = VERDICT_WARN
return report
def summarize_question_reports(reports: list[dict]) -> dict[str, Any]:
"""Reduce a list of per-question reports to an aggregate verdict."""
if not reports:
return {"verdict": VERDICT_OK, "total": 0, "failed": 0,
"warned": 0, "avg_score": 1.0}
failed = sum(1 for r in reports if r.get("verdict") == VERDICT_FAIL)
warned = sum(1 for r in reports if r.get("verdict") == VERDICT_WARN)
avg = sum(float(r.get("score") or 0) for r in reports) / max(1, len(reports))
if failed:
verdict = VERDICT_FAIL
elif warned:
verdict = VERDICT_WARN
else:
verdict = VERDICT_OK
return {
"verdict": verdict,
"total": len(reports),
"failed": failed,
"warned": warned,
"avg_score": round(avg, 3),
}
def dumps_report(report: dict) -> str:
"""Compact JSON dump safe for storing in a Text column."""
try:
return json.dumps(report, ensure_ascii=False, default=str)
except Exception:
return "{}"

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,25 @@
"""Authentication endpoints.
Split-token design:
* ``access_token`` — short-lived (1h), stateless, verified via signature only.
Sent on every request as ``Authorization: Bearer <access_token>``.
* ``refresh_token`` — long-lived (7d), stateful, backed by
``encoach.jwt.token``. Never sent on regular API calls; only to
``/api/auth/refresh``. Rotated on every refresh so a leaked token is
usable at most once.
The frontend should store ``refresh_token`` in a secure, non-extractable
location (ideally ``httpOnly`` cookie if the deployment proxy allows it,
otherwise ``localStorage`` behind an explicit trust boundary) and call
``/api/auth/refresh`` in the background whenever the access token is within
2 minutes of expiry.
"""
import logging
import time
import uuid
from datetime import datetime, timedelta
import jwt as pyjwt
@@ -13,6 +33,40 @@ from .base import (
_logger = logging.getLogger(__name__)
ACCESS_TTL_SECONDS = 3600 # 1 hour
REFRESH_TTL_SECONDS = 7 * 86400 # 7 days
def _issue_tokens(user, *, user_agent=None, remote_ip=None):
"""Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple.
Persisting the refresh token is the caller's job — they get the ``jti``
back so they can insert exactly one row.
"""
secret = _get_jwt_secret()
now = int(time.time())
access_token = pyjwt.encode(
{
"user_id": user.id,
"type": "access",
"iat": now,
"exp": now + ACCESS_TTL_SECONDS,
},
secret, algorithm="HS256",
)
refresh_jti = uuid.uuid4().hex
refresh_token = pyjwt.encode(
{
"user_id": user.id,
"type": "refresh",
"jti": refresh_jti,
"iat": now,
"exp": now + REFRESH_TTL_SECONDS,
},
secret, algorithm="HS256",
)
return access_token, refresh_token, refresh_jti
class EncoachAuthController(http.Controller):
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
if not login or not password:
return _error_response('login and password are required', 400)
# Odoo 19: session.authenticate(env, credential_dict)
credential = {
'type': 'password',
'login': login,
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
user = request.env['res.users'].sudo().browse(uid)
# Generate JWT token
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
token = pyjwt.encode(
{'user_id': user.id, 'exp': int(time.time()) + 86400},
secret, algorithm='HS256',
)
# User-Agent / IP are best-effort — some proxies strip them; we
# record whatever we get but never refuse login because they're
# missing.
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
request.env['encoach.jwt.token'].sudo().create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
# Get permissions
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
# Update last login
user.write({'last_login': fields.Datetime.now()})
return _json_response({
'token': token,
'token': access_token, # legacy name (frontend fallback)
'access_token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
'user': self._user_to_dict(user),
'permissions': permissions,
})
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
_logger.exception('login failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/auth/refresh
# ------------------------------------------------------------------
@http.route('/api/auth/refresh', type='http', auth='public',
methods=['POST'], csrf=False)
def refresh(self, **kw):
"""Rotate a refresh token into a fresh access+refresh pair.
Consumes (revokes) the presented refresh token and issues new ones.
This is the *only* place a refresh token is ever accepted, so replay
attempts against other endpoints fail at the access-token layer.
"""
try:
body = _get_json_body()
token = body.get('refresh_token') or ''
if not token:
return _error_response('refresh_token is required', 400)
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
try:
claims = pyjwt.decode(token, secret, algorithms=['HS256'])
except pyjwt.ExpiredSignatureError:
return _error_response('refresh_token expired', 401)
except pyjwt.InvalidTokenError:
return _error_response('refresh_token invalid', 401)
if claims.get('type') != 'refresh':
return _error_response('wrong token type', 401)
jti = claims.get('jti')
user_id = claims.get('user_id')
if not jti or not user_id:
return _error_response('refresh_token malformed', 401)
Token = request.env['encoach.jwt.token'].sudo()
row = Token.search([
('jti', '=', jti),
('user_id', '=', user_id),
('revoked', '=', False),
], limit=1)
if not row:
# Either already rotated (possible replay) or never existed.
# Revoke every active token for that user as a defensive
# measure — stolen refresh tokens shouldn't buy multiple
# rotations.
Token.search([
('user_id', '=', user_id),
('revoked', '=', False),
]).write({'revoked': True})
return _error_response('refresh_token revoked', 401)
user = request.env['res.users'].sudo().browse(user_id)
if not user.exists() or not user.active:
return _error_response('user disabled', 401)
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
row.write({'revoked': True, 'last_used_at': fields.Datetime.now()})
Token.create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
return _json_response({
'access_token': access_token,
'token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
})
except Exception as e:
_logger.exception('refresh failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/user (returns current authenticated user)
# ------------------------------------------------------------------
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
@http.route('/api/logout', type='http', auth='public',
methods=['POST'], csrf=False)
def logout(self, **kw):
# JWT is stateless — client clears token. Server just returns OK.
"""Revoke the presented refresh token (if any) and return OK.
Access tokens remain stateless; they expire naturally within an hour.
Clients that care about immediate lockout should also delete the
access token from local storage when they call this endpoint.
"""
try:
body = _get_json_body() or {}
token = body.get('refresh_token') or ''
if token:
secret = _get_jwt_secret()
if secret:
try:
claims = pyjwt.decode(
token, secret, algorithms=['HS256'],
options={'verify_exp': False},
)
jti = claims.get('jti')
if jti:
request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti)
except pyjwt.InvalidTokenError:
# Invalid tokens can't be revoked but also can't be
# reused, so this is effectively a no-op.
pass
except Exception:
_logger.exception('logout cleanup failed')
return _json_response({'ok': True})
# ------------------------------------------------------------------

View File

@@ -2,6 +2,7 @@ import json
import functools
import logging
import time
import uuid
import jwt as pyjwt
@@ -9,14 +10,75 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
REQUEST_ID_HEADER = "X-Request-Id"
def _get_or_create_request_id():
"""Return the X-Request-Id for the current request, minting one if absent.
The value is cached on ``request`` so the same id is reused for every log
line emitted during the request lifecycle (dispatcher, controller,
post-dispatch). Call sites should prefer :func:`current_request_id`.
"""
existing = getattr(request, "_encoach_request_id", None)
if existing:
return existing
header_val = request.httprequest.headers.get(REQUEST_ID_HEADER)
req_id = (header_val or uuid.uuid4().hex)[:64]
try:
request._encoach_request_id = req_id
except Exception:
pass
return req_id
def current_request_id():
"""Best-effort accessor for the active request id (None outside a request)."""
try:
return getattr(request, "_encoach_request_id", None)
except Exception:
return None
def _log_json(level, event, **fields):
"""Emit a single structured JSON log line, enriched with the request id.
Keeping all non-interactive logs JSON-formatted lets ops scrape them with
Loki / OpenSearch without brittle regexes. Falls back silently if the
logging backend rejects the payload.
"""
try:
payload = {
"ts": round(time.time(), 3),
"event": event,
"rid": current_request_id(),
}
payload.update(fields)
_logger.log(level, json.dumps(payload, default=str, ensure_ascii=False))
except Exception:
_logger.log(level, "%s %s", event, fields)
_jwt_secret_cache = {"secret": None, "ts": 0}
_JWT_SECRET_TTL = 300
# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation
# via ir.config_parameter propagates quickly across all workers without
# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21.
_JWT_SECRET_TTL = 30
_user_exists_cache = {}
_USER_CACHE_TTL = 60
_USER_CACHE_MAX = 200
def invalidate_jwt_secret_cache():
"""Invalidate the per-worker JWT secret cache immediately.
Called by ``encoach.auth.settings`` whenever the admin rotates the secret
so that subsequent requests pick up the new value on the next read.
"""
_jwt_secret_cache["secret"] = None
_jwt_secret_cache["ts"] = 0
def _get_jwt_secret():
now = time.time()
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
@@ -48,6 +110,12 @@ def validate_token():
return None
except pyjwt.InvalidTokenError:
return None
# Refresh tokens are only accepted by /api/auth/refresh. Presenting one as
# a Bearer on any other endpoint is treated as unauthenticated so a
# leaked refresh token never buys API access on its own.
token_type = payload.get("type")
if token_type and token_type != "access":
return None
user_id = payload.get("user_id")
if not user_id:
return None
@@ -70,26 +138,95 @@ def validate_token():
def jwt_required(func):
"""Decorator that validates the JWT token and sets request.env user context."""
"""Decorator that validates the JWT token and sets request.env user context.
Also handles per-request observability plumbing:
- Assigns (or honours) an ``X-Request-Id`` so every log line, downstream
service call, and response can be correlated back to the same request.
- Emits a structured ``api.request.start`` / ``api.request.end`` log pair
with duration in ms, status code, user id, route, and method — powering
the P2.3 metrics pipeline once it lands without any extra wiring.
- Echoes the request id back to the caller via the ``X-Request-Id``
response header.
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
req_id = _get_or_create_request_id()
route = request.httprequest.path
method = request.httprequest.method
t0 = time.time()
user = validate_token()
if not user:
return _error_response("Authentication required", status=401)
_log_json(logging.INFO, "api.request.unauthorized",
route=route, method=method, status=401)
resp = _error_response("Authentication required", status=401)
try:
resp.headers[REQUEST_ID_HEADER] = req_id
except Exception:
pass
return resp
request.update_env(user=user.id)
return func(*args, **kwargs)
_log_json(logging.INFO, "api.request.start",
route=route, method=method, user_id=user.id)
try:
resp = func(*args, **kwargs)
status = getattr(resp, "status_code", 200)
duration_ms = int((time.time() - t0) * 1000)
_log_json(logging.INFO, "api.request.end",
route=route, method=method, user_id=user.id,
status=status, duration_ms=duration_ms)
try:
resp.headers[REQUEST_ID_HEADER] = req_id
except Exception:
pass
_record_metric(route, status, duration_ms)
return resp
except Exception as exc:
duration_ms = int((time.time() - t0) * 1000)
_log_json(logging.ERROR, "api.request.error",
route=route, method=method, user_id=user.id,
duration_ms=duration_ms,
error=type(exc).__name__, message=str(exc)[:500])
_record_metric(route, 500, duration_ms)
raise
return wrapper
def _record_metric(route, status, duration_ms):
"""Soft import of the metrics recorder so this module has no hard cycle."""
try:
from odoo.addons.encoach_api.controllers.openapi import record_request
record_request(route, status, duration_ms)
except Exception:
pass
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
resp = request.make_json_response(data, status=status)
try:
rid = current_request_id()
if rid:
resp.headers[REQUEST_ID_HEADER] = rid
except Exception:
pass
return resp
def _error_response(message, status=400, code=None):
body = {"error": message}
if code:
body["code"] = code
return request.make_json_response(body, status=status)
resp = request.make_json_response(body, status=status)
try:
rid = current_request_id()
if rid:
resp.headers[REQUEST_ID_HEADER] = rid
except Exception:
pass
return resp
def _get_json_body():
@@ -99,6 +236,31 @@ def _get_json_body():
return {}
def paginated_envelope(items, *, total=None, page=None, size=None, extra=None):
"""Canonical ``{items, total, page, size}`` envelope.
This is the platform-wide pagination shape — prefer returning the output of
this helper from any new list endpoint. Legacy keys (``data``, ``results``,
and custom aliases such as ``students`` / ``subjects``) may be added via
the ``extra`` dict for transitional backward compatibility with the
frontend services that still read those keys; new code must read ``items``.
"""
items = list(items or [])
envelope = {
"items": items,
"total": int(total if total is not None else len(items)),
}
if page is not None:
envelope["page"] = int(page)
if size is not None:
envelope["size"] = int(size)
if extra:
for k, v in extra.items():
if k not in envelope:
envelope[k] = v
return envelope
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
"""Paginate an Odoo model search or extract params from a kwargs dict.

View File

@@ -0,0 +1,316 @@
"""GDPR data-subject rights endpoints.
Exposes two routes:
* ``GET /api/gdpr/export``
Returns a JSON snapshot of the calling user's personal data — profile,
entity membership, exam attempts, answers, AI interactions, feedback,
tickets, and login history.
* ``POST /api/gdpr/delete``
Initiates the "right to erasure" flow. Because Odoo enforces FK
constraints on linked business records (exam attempts, tickets, audit
trail), we **anonymise** instead of hard-deleting: the ``res.users``
row is deactivated, ``res.partner`` PII fields are wiped, and a
tombstone row is written to ``encoach.gdpr.erasure.request`` for
audit. Hard delete of linked business rows (attempts, answers,
feedback) is performed for records where it is legally required and
where no other user depends on them.
The flow is intentionally verbose so operators can see what happened. A
production deployment may want to queue the erasure to a background job
and require a cooling-off period before executing; we expose a
``confirm`` flag for that.
"""
from __future__ import annotations
import json
import logging
from odoo import fields, http
from odoo.http import request
from .base import (
_error_response,
_get_json_body,
_json_response,
jwt_required,
)
_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _safe_records(env_name: str, domain, fields_to_read):
"""Read records if the model exists, else return []."""
env = request.env
if env_name not in env:
return []
try:
return env[env_name].sudo().search(domain).read(fields_to_read)
except Exception as exc:
_logger.warning("gdpr: could not read %s: %s", env_name, exc)
return []
def _sanitize(values):
"""Convert non-JSON-serialisable values to strings/nulls."""
out = {}
for key, value in values.items():
if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], int):
# Many2one read tuples — keep both id and display name.
out[key] = {"id": value[0], "display_name": value[1]}
elif isinstance(value, bytes):
out[key] = f"<{len(value)} bytes omitted>"
elif hasattr(value, "isoformat"):
out[key] = value.isoformat()
else:
out[key] = value
return out
# ----------------------------------------------------------------------
# Controller
# ----------------------------------------------------------------------
class EncoachGdprController(http.Controller):
"""Implements export + right-to-erasure endpoints."""
# ------------------------------------------------------------------
# GET /api/gdpr/export
# ------------------------------------------------------------------
@http.route("/api/gdpr/export", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def export(self, **kw):
try:
user = request.env.user
partner = user.partner_id
profile = {
"id": user.id,
"login": user.login,
"name": user.name,
"email": user.email or "",
"lang": user.lang or "",
"tz": user.tz or "",
"create_date": user.create_date.isoformat() if user.create_date else None,
"login_date": user.login_date.isoformat() if user.login_date else None,
}
partner_data = {
"id": partner.id if partner else None,
"name": partner.name if partner else None,
"email": partner.email if partner else None,
"phone": partner.phone if partner else None,
"mobile": partner.mobile if partner else None,
"street": partner.street if partner else None,
"city": partner.city if partner else None,
"country": partner.country_id.name if partner and partner.country_id else None,
}
# Entity / role memberships
entity_rel = _safe_records(
"encoach.user.entity.rel",
[("user_id", "=", user.id)],
["id", "entity_id", "role_id", "is_active"],
)
# Exam attempts & answers
attempts = _safe_records(
"encoach.student.attempt",
[("user_id", "=", user.id)],
["id", "exam_id", "status", "score", "started_at", "submitted_at"],
)
attempt_ids = [a["id"] for a in attempts]
answers = _safe_records(
"encoach.student.answer",
[("attempt_id", "in", attempt_ids)],
["id", "attempt_id", "question_id", "student_answer", "is_correct", "score"],
) if attempt_ids else []
# AI feedback they left
feedback = _safe_records(
"encoach.ai.feedback",
[("user_id", "=", user.id)],
["id", "subject_type", "subject_id", "rating", "comment", "status", "create_date"],
)
# AI calls they triggered (logs)
ai_logs = _safe_records(
"encoach.ai.log",
[("user_id", "=", user.id)] if "encoach.ai.log" in request.env else [],
["id", "service", "action", "model_used", "total_tokens", "status", "create_date"],
)
# Tickets they filed
tickets = _safe_records(
"encoach.ticket",
[("created_by_id", "=", user.id)],
["id", "name", "status", "priority", "assigned_to_id", "create_date"],
)
# Feedback on their coaching, if any
coaching_sessions = _safe_records(
"encoach.coaching.session",
[("user_id", "=", user.id)],
["id", "create_date", "summary"],
)
payload = {
"profile": profile,
"partner": partner_data,
"entity_memberships": [_sanitize(r) for r in entity_rel],
"exam_attempts": [_sanitize(r) for r in attempts],
"exam_answers": [_sanitize(r) for r in answers],
"ai_feedback": [_sanitize(r) for r in feedback],
"ai_calls": [_sanitize(r) for r in ai_logs],
"tickets": [_sanitize(r) for r in tickets],
"coaching_sessions": [_sanitize(r) for r in coaching_sessions],
"exported_at": fields.Datetime.to_string(fields.Datetime.now()),
"export_format_version": "1.0",
}
return _json_response(payload)
except Exception as e:
_logger.exception("gdpr export failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/gdpr/delete
# ------------------------------------------------------------------
@http.route("/api/gdpr/delete", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def delete(self, **kw):
try:
body = _get_json_body() or {}
if not body.get("confirm"):
return _error_response(
"Set {\"confirm\": true} to initiate erasure. "
"This action cannot be undone.",
400,
)
user = request.env.user
if user.id == 1 or user.has_group("base.group_system"):
return _error_response(
"Admin accounts cannot self-erase via this endpoint. "
"Contact the data protection officer.",
403,
)
login = user.login
email = user.email or ""
archived_id = user.id
summary = {
"anonymised_partner_fields": [],
"deactivated_user": False,
"deleted_feedback_count": 0,
"deleted_coaching_count": 0,
"retained_attempts_count": 0,
}
with request.env.cr.savepoint():
# 1. Anonymise the partner PII.
partner = user.partner_id
if partner:
partner.sudo().write({
"name": f"deleted-user-{archived_id}",
"email": False,
"phone": False,
"mobile": False,
"street": False,
"street2": False,
"city": False,
"zip": False,
"image_1920": False,
"comment": "Erased per GDPR request.",
})
summary["anonymised_partner_fields"] = [
"name", "email", "phone", "mobile",
"street", "street2", "city", "zip",
"image_1920", "comment",
]
# 2. Hard-delete their feedback (personal reviews/comments).
if "encoach.ai.feedback" in request.env:
feedback = request.env["encoach.ai.feedback"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_feedback_count"] = len(feedback)
feedback.unlink()
# 3. Hard-delete coaching session transcripts (personal).
if "encoach.coaching.session" in request.env:
sessions = request.env["encoach.coaching.session"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_coaching_count"] = len(sessions)
sessions.unlink()
# 4. Retain exam attempts (aggregate analytics rely on them)
# but strip personal free-text fields where they exist.
if "encoach.student.attempt" in request.env:
retained = request.env["encoach.student.attempt"].sudo().search([
("user_id", "=", user.id),
])
summary["retained_attempts_count"] = len(retained)
# Clear any written responses that contain user PII — keep
# the scores/timestamps for analytics integrity.
if "encoach.student.answer" in request.env:
answers = request.env["encoach.student.answer"].sudo().search([
("attempt_id", "in", retained.ids),
])
# student_answer can contain free-text PII.
answers.write({"student_answer": False})
# 5. Rewrite AI log user_id → None so we can no longer tie
# those rows to the person.
if "encoach.ai.log" in request.env:
ai_logs = request.env["encoach.ai.log"].sudo().search([
("user_id", "=", user.id),
])
if ai_logs:
try:
ai_logs.write({"user_id": False})
except Exception:
# user_id may be required; fall back to deletion.
ai_logs.unlink()
# 6. Deactivate the user (we can't unlink — FKs).
user.sudo().write({
"active": False,
"login": f"erased-{archived_id}@example.invalid",
})
summary["deactivated_user"] = True
# 7. Write tombstone audit row.
request.env["encoach.gdpr.erasure.request"].sudo().create({
"user_login": login,
"user_email": email,
"user_id_archived": archived_id,
"status": "completed",
"summary": json.dumps(summary),
"completed_at": fields.Datetime.now(),
})
# Blow away any cached JWT sessions for this user.
try:
from odoo.addons.encoach_api.utils.jwt_cache import (
invalidate_user as _invalidate_user,
)
_invalidate_user(archived_id)
except Exception as exc:
_logger.debug("jwt cache invalidation skipped: %s", exc)
return _json_response({
"ok": True,
"summary": summary,
"message": (
"Your account has been anonymised and deactivated. "
"Some records (e.g. exam attempts) are retained "
"without personal identifiers for statistical purposes."
),
})
except Exception as e:
_logger.exception("gdpr delete failed")
return _error_response(str(e), 500)

View File

@@ -0,0 +1,65 @@
"""Platform health probes (P0.4).
Exposes unauthenticated ``/api/health`` (quick liveness) and ``/api/health/ready``
(deep readiness: DB + JWT secret + OpenAI key). Designed to be consumed by
uptime monitors, load-balancer health checks and container orchestrators.
"""
import logging
import time
from odoo import http
from odoo.http import request
from odoo.release import version as odoo_version
_logger = logging.getLogger(__name__)
_STARTED_AT = time.time()
class HealthController(http.Controller):
"""Platform health probes — safe to expose publicly."""
@http.route("/api/health", type="http", auth="none", methods=["GET"], csrf=False)
def health(self, **_kw):
"""Liveness probe: the process is up and able to handle HTTP."""
return request.make_json_response({
"status": "ok",
"service": "encoach-backend",
"odoo_version": odoo_version,
"uptime_seconds": int(time.time() - _STARTED_AT),
})
@http.route("/api/health/ready", type="http", auth="none", methods=["GET"], csrf=False)
def ready(self, **_kw):
"""Readiness probe: DB reachable, JWT secret configured, AI key known."""
checks = {}
ok = True
try:
request.env.cr.execute("SELECT 1")
checks["database"] = "ok"
except Exception as exc:
ok = False
checks["database"] = f"error: {exc}"
try:
IrParam = request.env["ir.config_parameter"].sudo()
checks["jwt_secret"] = "ok" if IrParam.get_param("encoach.jwt_secret") else "missing"
if checks["jwt_secret"] == "missing":
ok = False
ai_key = IrParam.get_param("encoach_ai.openai_api_key")
import os
if not ai_key:
ai_key = os.environ.get("OPENAI_API_KEY", "")
checks["openai_key"] = "ok" if ai_key else "missing"
except Exception as exc:
ok = False
checks["config"] = f"error: {exc}"
status = 200 if ok else 503
return request.make_json_response({
"status": "ok" if ok else "degraded",
"checks": checks,
"uptime_seconds": int(time.time() - _STARTED_AT),
}, status=status)

View File

@@ -0,0 +1,265 @@
"""OpenAPI 3.0 + Prometheus-ish metrics exporter.
Both endpoints are **unauthenticated** by design so they can be scraped by
third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing
to solve a JWT exchange first. The OpenAPI spec is generated by introspecting
every class decorated with :func:`odoo.http.route` and therefore stays in sync
with the running server automatically.
Scope: the generator emits a minimal but valid OpenAPI document — routes,
HTTP methods, path params, tag derived from the route prefix, and a shared
``BearerAuth`` security scheme. Request / response schemas are left open
(``{"type": "object"}``) since we don't have pydantic models yet; a future
iteration can enrich individual routes via ``route_decorated_method.openapi``
annotations.
"""
from __future__ import annotations
import logging
import re
import time
from collections import Counter, defaultdict
from odoo import http
from odoo.http import Controller, Response, request
_logger = logging.getLogger(__name__)
_metrics_lock = None
_metrics: dict = {
"started_at": time.time(),
"routes": Counter(), # key -> hit count
"statuses": Counter(), # status bucket -> hit count
"latencies_ms": defaultdict(list), # route -> [recent ms samples] (cap 128)
}
_LATENCY_SAMPLE_CAP = 128
_PATH_PARAM_RE = re.compile(r"<(?:(?P<conv>\w+):)?(?P<name>\w+)>")
def record_request(route: str, status: int, duration_ms: int) -> None:
"""Lightweight in-process request counter used by the metrics endpoint.
Intentionally thread-unsafe for minimum overhead; worst case is a slightly
off counter on concurrent writes which is fine for ops dashboards. Switch
to a proper Prometheus client (``prometheus_client``) in P2.3 when needed.
"""
try:
_metrics["routes"][route] += 1
bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx"
_metrics["statuses"][bucket] += 1
samples = _metrics["latencies_ms"][route]
samples.append(int(duration_ms))
if len(samples) > _LATENCY_SAMPLE_CAP:
del samples[0:len(samples) - _LATENCY_SAMPLE_CAP]
except Exception:
pass
def _convert_odoo_path(path: str) -> str:
"""Translate Odoo's ``<int:foo>`` placeholder syntax to OpenAPI ``{foo}``."""
def repl(m: re.Match) -> str:
return "{" + m.group("name") + "}"
return _PATH_PARAM_RE.sub(repl, path)
def _path_parameters(path: str) -> list[dict]:
params = []
for m in _PATH_PARAM_RE.finditer(path):
conv = (m.group("conv") or "string").lower()
if conv in ("int", "integer"):
schema = {"type": "integer"}
elif conv in ("float", "number"):
schema = {"type": "number"}
else:
schema = {"type": "string"}
params.append({
"name": m.group("name"),
"in": "path",
"required": True,
"schema": schema,
})
return params
def _iter_routes():
"""Yield ``(route_str, method_name, tag, class_name, doc)`` for every
``@http.route`` decorated callable currently registered.
Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the
dispatcher actually routes to — including extension classes.
"""
seen = set()
try:
routing_map = request.env["ir.http"].routing_map()
except Exception:
_logger.exception("could not fetch routing_map")
return
try:
rules = list(routing_map.iter_rules())
except Exception:
_logger.exception("routing_map has no iter_rules")
return
for rule in rules:
url = rule.rule
if not isinstance(url, str) or not url.startswith("/api/"):
continue
endpoint = rule.endpoint
# ``endpoint`` is a wrapped callable; the real controller method is
# stashed on ``endpoint.func`` by Odoo.
fn = getattr(endpoint, "func", endpoint)
routing = getattr(fn, "routing", {}) or {}
methods = routing.get("methods") or list(rule.methods or {"GET"})
methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}]
if not methods:
methods = ["GET"]
cls_name = ""
try:
qual = getattr(fn, "__qualname__", fn.__name__)
cls_name = qual.split(".")[0] if "." in qual else ""
except Exception:
cls_name = ""
module = getattr(fn, "__module__", "") or ""
tag_base = module.split(".")[-2] if "." in module else module or "api"
doc = ""
try:
doc = (fn.__doc__ or "").strip()
except Exception:
pass
for m in methods:
key = (url, m.upper(), cls_name, fn.__name__)
if key in seen:
continue
seen.add(key)
yield url, m.upper(), tag_base, cls_name, doc
def _build_openapi_spec() -> dict:
base_url = (
request.env["ir.config_parameter"].sudo().get_param("web.base.url")
or "http://localhost:8069"
)
paths: dict[str, dict] = {}
tags: dict[str, str] = {}
for route, method, tag_base, cls_name, doc in _iter_routes():
openapi_path = _convert_odoo_path(route)
path_entry = paths.setdefault(openapi_path, {})
# Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``).
segs = [s for s in route.split("/") if s and s != "api"]
tag = segs[0] if segs else tag_base
tags.setdefault(tag, f"Endpoints under /api/{tag}")
summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}"
op: dict = {
"summary": summary,
"operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}",
"tags": [tag],
"security": [{"BearerAuth": []}],
"responses": {
"200": {"description": "OK",
"content": {"application/json":
{"schema": {"type": "object"}}}},
"401": {"description": "Authentication required"},
"400": {"description": "Bad request"},
"500": {"description": "Server error"},
},
}
params = _path_parameters(route)
if params:
op["parameters"] = params
if method in ("POST", "PUT", "PATCH"):
op["requestBody"] = {
"required": False,
"content": {"application/json":
{"schema": {"type": "object"}}},
}
path_entry[method.lower()] = op
return {
"openapi": "3.0.3",
"info": {
"title": "EnCoach Platform API",
"version": "19.0.1.0",
"description": (
"Auto-generated from ``@http.route`` decorators. "
"Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the "
"roadmap to enrich request/response shapes."
),
},
"servers": [{"url": base_url}],
"tags": [{"name": name, "description": desc}
for name, desc in sorted(tags.items())],
"components": {
"securitySchemes": {
"BearerAuth": {
"type": "http", "scheme": "bearer", "bearerFormat": "JWT",
},
},
},
"paths": dict(sorted(paths.items())),
}
class EncoachOpenAPIController(Controller):
@http.route("/api/openapi.json", type="http", auth="none",
methods=["GET"], csrf=False)
def openapi(self, **_kw):
"""Return a freshly generated OpenAPI 3.0 spec for the running server."""
try:
spec = _build_openapi_spec()
return request.make_json_response(spec)
except Exception:
_logger.exception("openapi generation failed")
return request.make_json_response(
{"error": "could not generate openapi spec"}, status=500,
)
@http.route("/api/metrics", type="http", auth="none",
methods=["GET"], csrf=False)
def metrics(self, **_kw):
"""Return in-process request counters in a Prometheus-compatible text format.
This is a minimalist shim, not a full Prometheus client. It exposes:
- ``encoach_requests_total{route, method, status_bucket}`` — request count
- ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based)
- ``encoach_uptime_seconds`` — process uptime
"""
lines = [
"# HELP encoach_uptime_seconds Seconds since this worker booted",
"# TYPE encoach_uptime_seconds gauge",
f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}",
"",
"# HELP encoach_requests_total Total number of /api/* requests handled",
"# TYPE encoach_requests_total counter",
]
for route, count in _metrics["routes"].most_common():
safe = route.replace('"', '\\"')
lines.append(f'encoach_requests_total{{route="{safe}"}} {count}')
lines.append("")
lines.append("# HELP encoach_responses_total Count of responses per status bucket")
lines.append("# TYPE encoach_responses_total counter")
for bucket, count in _metrics["statuses"].items():
lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}')
lines.append("")
lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route")
lines.append("# TYPE encoach_request_latency_ms_p95 gauge")
for route, samples in _metrics["latencies_ms"].items():
if not samples:
continue
sorted_samples = sorted(samples)
p95 = sorted_samples[min(len(sorted_samples) - 1,
int(0.95 * len(sorted_samples)))]
safe = route.replace('"', '\\"')
lines.append(
f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}'
)
body = "\n".join(lines) + "\n"
return Response(body, status=200,
content_type="text/plain; version=0.0.4")

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
"""Tombstone record written when a user exercises the right-to-erasure.
We cannot hard-delete the original res.users row (ORM FK constraints on
attempts, tickets, audit trail, etc.), so this table lets us prove the
request happened, what was anonymised/deleted, and when.
"""
from odoo import fields, models
class EncoachGdprErasureRequest(models.Model):
_name = "encoach.gdpr.erasure.request"
_description = "GDPR right-to-erasure audit trail"
_order = "create_date desc"
user_login = fields.Char(required=True, index=True)
user_email = fields.Char(index=True)
user_id_archived = fields.Integer(
index=True,
help="Id of the res.users row at time of erasure (row is now archived).",
)
status = fields.Selection(
[
("requested", "Requested"),
("completed", "Completed"),
("partial", "Partial — manual follow-up required"),
],
default="requested", required=True,
)
summary = fields.Text(
help="JSON summary of what was deleted/anonymised/retained.",
)
requested_at = fields.Datetime(default=fields.Datetime.now)
completed_at = fields.Datetime()
notes = fields.Text()

View File

@@ -0,0 +1,105 @@
"""Refresh-token ledger for the EnCoach API.
Access tokens stay short-lived (1 hour) and stateless — they are verified
with the JWT signature alone. Refresh tokens, on the other hand, must be
revocable so compromised devices can be signed out without rotating the
server secret. Every refresh token corresponds to one row here, keyed by a
UUID (``jti``) that the client echoes back on ``/api/auth/refresh``.
Rotation policy: ``/api/auth/refresh`` consumes the presented row (revokes
it) and issues a brand-new row, so a stolen refresh token only works once.
Replay attempts after rotation will be rejected because the replayed ``jti``
is no longer ``active``.
"""
from __future__ import annotations
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class EncoachJwtToken(models.Model):
_name = "encoach.jwt.token"
_description = "JWT Refresh Token Ledger"
_order = "issued_at desc"
_rec_name = "jti"
jti = fields.Char(
string="Token ID",
required=True,
index=True,
help="UUID used as the JWT's `jti` claim; serves as the primary handle.",
)
user_id = fields.Many2one(
"res.users",
string="User",
required=True,
ondelete="cascade",
index=True,
)
issued_at = fields.Datetime(required=True, default=fields.Datetime.now)
expires_at = fields.Datetime(required=True)
last_used_at = fields.Datetime()
revoked = fields.Boolean(default=False, index=True)
user_agent = fields.Char(help="User-Agent header of the issuing client, for audit.")
remote_ip = fields.Char(help="Remote IP of the issuing client, for audit.")
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
for name, ddl in (
(
"encoach_jwt_token_jti_unique",
"CREATE UNIQUE INDEX IF NOT EXISTS encoach_jwt_token_jti_unique "
"ON encoach_jwt_token (jti)",
),
(
"encoach_jwt_token_user_revoked_idx",
"CREATE INDEX IF NOT EXISTS encoach_jwt_token_user_revoked_idx "
"ON encoach_jwt_token (user_id, revoked, expires_at)",
),
):
try:
cr.execute(ddl)
except Exception:
_logger.warning("could not create index %s", name, exc_info=True)
return res
@api.model
def revoke_by_jti(self, jti: str) -> bool:
"""Soft-revoke every row matching ``jti``.
Returns ``True`` iff at least one row was flipped. Called from
``/api/auth/refresh`` (to consume the rotated token) and
``/api/logout`` (to end the session).
"""
if not jti:
return False
rows = self.sudo().search([("jti", "=", jti), ("revoked", "=", False)])
if not rows:
return False
rows.write({"revoked": True})
return True
@api.model
def purge_expired(self, batch_size: int = 500) -> int:
"""Remove rows that expired more than a day ago.
Wired to the daily cleanup cron in :file:`data/cron.xml`; manual
invocations (e.g. migrations) should pass a larger ``batch_size``
to avoid multi-pass overhead.
"""
from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(days=1)
rows = self.sudo().search(
[("expires_at", "<", cutoff)],
limit=batch_size,
)
n = len(rows)
if n:
rows.unlink()
return n

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_jwt_token_user,encoach.jwt.token user,model_encoach_jwt_token,base.group_user,1,0,0,0
access_encoach_jwt_token_admin,encoach.jwt.token admin,model_encoach_jwt_token,base.group_system,1,1,1,1
access_gdpr_erasure_admin,encoach.gdpr.erasure.request admin,model_encoach_gdpr_erasure_request,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_jwt_token_user encoach.jwt.token user model_encoach_jwt_token base.group_user 1 0 0 0
3 access_encoach_jwt_token_admin encoach.jwt.token admin model_encoach_jwt_token base.group_system 1 1 1 1
4 access_gdpr_erasure_admin encoach.gdpr.erasure.request admin model_encoach_gdpr_erasure_request base.group_system 1 1 1 1

View File

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

View File

@@ -0,0 +1,27 @@
"""Smoke tests for the health + GDPR endpoints.
Run with:
./odoo-bin -c odoo.conf --test-tags encoach_api --stop-after-init
"""
import json
from odoo.tests import HttpCase, tagged
@tagged("post_install", "-at_install", "encoach_api")
class TestHealthEndpoints(HttpCase):
def test_health_live_returns_200(self):
"""``GET /api/health`` must always return 200 once Odoo has booted."""
response = self.url_open("/api/health", timeout=10)
self.assertEqual(response.status_code, 200)
body = response.json() if hasattr(response, "json") else json.loads(response.text)
self.assertTrue(body.get("ok") is True or body.get("status") in {"ok", "live"})
def test_health_ready_returns_2xx(self):
"""``GET /api/health/ready`` is allowed to flap, but must not 5xx."""
response = self.url_open("/api/health/ready", timeout=10)
self.assertIn(
response.status_code, (200, 503),
f"unexpected status {response.status_code}: {response.text[:200]}",
)

View File

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

View File

@@ -0,0 +1,106 @@
"""Zero-dependency in-process TTL cache for hot endpoints.
Designed for read-heavy, mostly-idempotent endpoints like the admin report
pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``)
and the AI narrative generator. A single LRU-ish dict per worker with a hard
time bound is usually enough to absorb the dashboard refresh storm caused by
an admin tabbing between pages.
**Not** a substitute for Redis — cross-worker consistency and bounded memory
are out of scope. If ops ever needs those guarantees, swap the internal dict
for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and
``invalidate``) stays identical.
"""
from __future__ import annotations
import hashlib
import json
import logging
import threading
import time
from functools import wraps
from typing import Any, Callable
_logger = logging.getLogger(__name__)
_cache: dict[str, tuple[float, Any]] = {}
_lock = threading.Lock()
_MAX_ENTRIES = 512
def _make_key(namespace: str, args: tuple, kwargs: dict) -> str:
payload = json.dumps([args, kwargs], sort_keys=True, default=str)
digest = hashlib.md5(payload.encode("utf-8")).hexdigest()
return f"{namespace}:{digest}"
def get(key: str):
entry = _cache.get(key)
if not entry:
return None
expiry, value = entry
if expiry < time.time():
with _lock:
_cache.pop(key, None)
return None
return value
def put(key: str, value, ttl_seconds: int) -> None:
with _lock:
if len(_cache) >= _MAX_ENTRIES:
oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4]
for k, _v in oldest:
_cache.pop(k, None)
_cache[key] = (time.time() + max(1, ttl_seconds), value)
def invalidate(namespace: str | None = None) -> int:
"""Drop every cached entry, or just entries under ``namespace``.
Returns the number of removed entries. Callers should invoke this from
write endpoints that affect the cached read so subsequent reads observe
the new state.
"""
removed = 0
with _lock:
if namespace is None:
removed = len(_cache)
_cache.clear()
return removed
prefix = f"{namespace}:"
keys = [k for k in _cache if k.startswith(prefix)]
for k in keys:
_cache.pop(k, None)
removed = len(keys)
return removed
def memoize_ttl(namespace: str, ttl_seconds: int = 30):
"""Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key.
The key is derived from the namespace + a stable JSON dump of the args,
so callers don't need to worry about mutable keyword order or unhashable
defaults. JWT decorator should run *before* this one so unauthenticated
traffic never hits the cache.
"""
def decorator(func: Callable):
@wraps(func)
def wrapper(*args, **kwargs):
try:
key = _make_key(namespace, args, kwargs)
except Exception:
return func(*args, **kwargs)
cached = get(key)
if cached is not None:
return cached
value = func(*args, **kwargs)
try:
put(key, value, ttl_seconds)
except Exception:
_logger.debug("cache put failed for %s", namespace, exc_info=True)
return value
wrapper._encoach_cache_namespace = namespace
return wrapper
return decorator

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="0">
<data noupdate="1">
<!-- Global permissions (PermissionType from permissions.ts) -->
<record id="perm_viewCorporate" model="encoach.permission">
<field name="code">viewCorporate</field>

View File

@@ -6,7 +6,9 @@ class EncoachPermission(models.Model):
_description = "EnCoach Permission"
code = fields.Char(required=True, index=True)
topic = fields.Char()
name = fields.Char()
description = fields.Text()
topic = fields.Char(string="Category")
legacy_id = fields.Char(index=True)
_code_unique = models.Constraint(

View File

@@ -1,4 +1,4 @@
from odoo import models, fields
from odoo import models, fields, api
class EncoachRole(models.Model):
@@ -6,10 +6,22 @@ class EncoachRole(models.Model):
_description = "EnCoach Role"
name = fields.Char(required=True)
code = fields.Char(index=True)
description = fields.Text()
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
permission_ids = fields.Many2many("encoach.permission", string="Permissions")
user_ids = fields.Many2many(
"res.users", "encoach_role_user_rel", "role_id", "user_id",
string="Users",
)
is_default = fields.Boolean(default=False)
legacy_id = fields.Char(index=True)
user_count = fields.Integer(compute="_compute_user_count", store=True)
@api.depends("user_ids")
def _compute_user_count(self):
for rec in self:
rec.user_count = len(rec.user_ids)
def to_encoach_dict(self):
self.ensure_one()

View File

@@ -11,6 +11,7 @@
'data/ielts_templates.xml',
'data/sample_passages.xml',
'data/sample_questions.xml',
'data/ir_cron_schedule.xml',
'views/exam_template_views.xml',
'views/passage_views.xml',
'views/audio_file_views.xml',

View File

@@ -3,4 +3,8 @@ from . import ielts_exam
from . import custom_exam
from . import exam_structures
from . import assignments
from . import exam_schedules
from . import rubrics
from . import approval_workflows
from . import entities
from . import review_workflow

View File

@@ -0,0 +1,358 @@
"""Approval Workflow API.
Replaces the earlier raw-SQL implementation with a standard Odoo ORM
controller. Backing models live in ``encoach_exam_template/models/approval.py``.
Serves the admin "Approval Config" page (``/admin/approval-config``).
"""
import logging
from datetime import datetime
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
# ── Serializers ─────────────────────────────────────────────────────────────
def _ser_stage(stage):
"""Stages are written as ``sequence`` (ORM) but the admin UI speaks
``order`` (1-based, dense). We emit both so either side works."""
return {
'id': stage.id,
'order': stage.sequence,
'sequence': stage.sequence,
'approver_id': stage.approver_id.id or None,
'approver_name': stage.approver_id.name if stage.approver_id else '',
'max_days': stage.max_days or 0,
'auto_escalate': bool(stage.auto_escalate),
'notification_email': stage.notification_email or '',
'status': stage.status or 'pending',
'comment': stage.comment or '',
'acted_at': stage.acted_at.isoformat() if stage.acted_at else None,
}
def _ser_workflow(wf):
return {
'id': wf.id,
'name': wf.name or '',
'type': wf.type or 'custom',
'status': wf.status or 'draft',
'allow_bypass': bool(wf.allow_bypass),
'entity_id': wf.entity_id.id or None,
'entity_name': wf.entity_id.name if wf.entity_id else None,
'steps': [_ser_stage(s) for s in wf.stage_ids.sorted('sequence')],
'stage_count': wf.stage_count,
'request_count': wf.request_count,
'created_at': wf.create_date.isoformat() if wf.create_date else None,
# Legacy shape kept for any older callers that read `created`.
'created': wf.create_date.strftime('%Y-%m-%d') if wf.create_date else '',
}
def _ser_request(req):
stage = req.current_stage_id
return {
'id': req.id,
'workflow_id': req.workflow_id.id or None,
'workflow_name': req.workflow_id.name if req.workflow_id else '',
'res_model': req.res_model or '',
'res_id': req.res_id or 0,
'state': req.state or 'draft',
'requester_id': req.requester_id.id or None,
'requester_name': req.requester_id.name if req.requester_id else '',
'current_stage_id': stage.id or None,
'current_stage_sequence': stage.sequence if stage else None,
'bypass_reason': req.bypass_reason or '',
'created_at': req.created_at.isoformat() if req.created_at else None,
}
# ── Write helpers ───────────────────────────────────────────────────────────
def _apply_workflow_writes(body, vals):
for k in ('name', 'type'):
if k in body and body[k] is not None:
vals[k] = body[k]
if 'status' in body and body['status'] in ('draft', 'active', 'archived'):
vals['status'] = body['status']
if 'allow_bypass' in body:
vals['allow_bypass'] = bool(body['allow_bypass'])
# Frontend sends `bypass_enabled` from the workflow-builder dialog.
if 'bypass_enabled' in body:
vals['allow_bypass'] = bool(body['bypass_enabled'])
if 'entity_id' in body:
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
return vals
def _stage_vals_from_body(step, default_seq):
"""Accept either `order` (UI) or `sequence` (ORM). Emit `sequence` only."""
seq = step.get('sequence')
if seq is None:
seq = step.get('order', default_seq)
try:
seq = int(seq)
except (TypeError, ValueError):
seq = default_seq
approver_id = step.get('approver_id')
try:
approver_id = int(approver_id) if approver_id else 0
except (TypeError, ValueError):
approver_id = 0
vals = {
'sequence': seq,
'approver_id': approver_id or False,
'max_days': int(step.get('max_days') or 3),
'auto_escalate': bool(step.get('auto_escalate')),
'notification_email': step.get('notification_email') or False,
}
return vals
# ── Controller ──────────────────────────────────────────────────────────────
class ApprovalWorkflowController(http.Controller):
# ── Workflows ────────────────────────────────────────────────
@http.route('/api/approval-workflows', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_workflows(self, **kw):
try:
M = request.env['encoach.approval.workflow'].sudo()
domain = []
if kw.get('status'):
domain.append(('status', '=', kw['status']))
if kw.get('entity_id'):
domain.append(('entity_id', '=', int(kw['entity_id'])))
recs = M.search(domain, order='create_date desc')
items = [_ser_workflow(r) for r in recs]
# Envelope supports both {items,total} (legacy) and {results,total}
# (PaginatedResponse shape used by the approvals frontend service).
return _json_response({
'items': items,
'results': items,
'total': len(items),
})
except Exception as e:
_logger.exception('list approval workflows failed')
return _error_response(str(e), 500)
@http.route('/api/approval-workflows', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_workflow(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
vals = {'name': name, 'type': body.get('type', 'custom')}
_apply_workflow_writes(body, vals)
steps = body.get('steps') or []
stage_cmds = []
for i, step in enumerate(steps):
sv = _stage_vals_from_body(step, (i + 1) * 10)
if sv.get('approver_id'):
stage_cmds.append((0, 0, sv))
if stage_cmds:
vals['stage_ids'] = stage_cmds
rec = request.env['encoach.approval.workflow'].sudo().create(vals)
return _json_response(_ser_workflow(rec), 201)
except Exception as e:
_logger.exception('create approval workflow failed')
return _error_response(str(e), 500)
@http.route('/api/approval-workflows/<int:wf_id>', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_workflow(self, wf_id, **kw):
try:
rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_workflow(rec))
except Exception as e:
_logger.exception('get approval workflow failed')
return _error_response(str(e), 500)
@http.route('/api/approval-workflows/<int:wf_id>', type='http',
auth='none', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_workflow(self, wf_id, **kw):
try:
rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
_apply_workflow_writes(body, vals)
# Replace stages only when steps key is present in the body so
# a status-only toggle doesn't wipe stages.
if 'steps' in body and isinstance(body['steps'], list):
cmds = [(5, 0, 0)]
for i, step in enumerate(body['steps']):
sv = _stage_vals_from_body(step, (i + 1) * 10)
if sv.get('approver_id'):
cmds.append((0, 0, sv))
vals['stage_ids'] = cmds
if vals:
rec.write(vals)
return _json_response(_ser_workflow(rec))
except Exception as e:
_logger.exception('update approval workflow failed')
return _error_response(str(e), 500)
@http.route('/api/approval-workflows/<int:wf_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_workflow(self, wf_id, **kw):
try:
rec = request.env['encoach.approval.workflow'].sudo().browse(wf_id)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete approval workflow failed')
return _error_response(str(e), 500)
# ── Approval Requests ────────────────────────────────────────
@http.route('/api/approval-requests', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_requests(self, **kw):
try:
M = request.env['encoach.approval.request'].sudo()
domain = []
if kw.get('state'):
domain.append(('state', '=', kw['state']))
if kw.get('workflow_id'):
domain.append(('workflow_id', '=', int(kw['workflow_id'])))
recs = M.search(domain, order='created_at desc, id desc')
items = [_ser_request(r) for r in recs]
return _json_response({
'items': items,
'results': items,
'total': len(items),
})
except Exception as e:
_logger.exception('list approval requests failed')
return _error_response(str(e), 500)
@http.route('/api/approval-requests/<int:req_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve_request(self, req_id, **kw):
"""Approve the current stage or finalize the request.
Wrapped in a ``SAVEPOINT`` so stage + request writes commit or roll back
atomically. The savepoint is released only after both writes succeed,
preventing partial states (e.g. stage marked ``approved`` while request
still ``in_progress``) that were possible with the previous
non-transactional implementation.
"""
try:
body = _get_json_body()
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
stage_ids = [s.id for s in stages]
try:
idx = stage_ids.index(stage.id) if stage else -1
except ValueError:
idx = -1
if 0 <= idx and idx + 1 < len(stage_ids):
next_stage = request.env['encoach.approval.stage'].sudo().browse(
stage_ids[idx + 1]
)
req_rec.write({
'current_stage_id': next_stage.id,
'state': 'in_progress',
})
else:
req_rec.write({'state': 'approved'})
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
except Exception as e:
_logger.exception('approve request failed')
return _error_response(str(e), 500)
@http.route('/api/approval-requests/<int:req_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject_request(self, req_id, **kw):
"""Reject a stage and mark the request rejected atomically.
Uses a ``SAVEPOINT`` so that if the request ``write`` fails after the
stage has been updated, Postgres rolls both writes back and the caller
sees a clean 500 rather than a half-updated workflow.
"""
try:
body = _get_json_body()
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'rejected',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
req_rec.write({'state': 'rejected'})
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
except Exception as e:
_logger.exception('reject request failed')
return _error_response(str(e), 500)
# ── Approvers picker ─────────────────────────────────────────
@http.route('/api/approval-users', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_users(self, **kw):
try:
Users = request.env['res.users'].sudo()
domain = [('active', '=', True), ('id', '>', 1)]
search = (kw.get('search') or '').strip()
if search:
domain += ['|', ('name', 'ilike', search), ('login', 'ilike', search)]
recs = Users.search(domain, order='name', limit=100)
items = [{
'id': u.id,
'name': u.name or u.login,
'login': u.login or '',
'email': u.email or '',
} for u in recs]
return _json_response({'items': items, 'results': items, 'total': len(items)})
except Exception as e:
_logger.exception('list approval users failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,175 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
ENTITY_MODEL = 'encoach.entity'
def _entity_to_dict(entity):
d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else {
'id': entity.id,
'name': entity.name,
'code': getattr(entity, 'code', '') or '',
'type': getattr(entity, 'type', '') or '',
'active': entity.active if hasattr(entity, 'active') else True,
'user_count': getattr(entity, 'user_count', 0) or 0,
}
d['role_count'] = len(entity.role_ids) if hasattr(entity, 'role_ids') else 0
return d
def _role_to_dict(role):
return {
'id': role.id,
'name': role.name,
'code': getattr(role, 'code', '') or '',
'entity_id': role.entity_id.id if hasattr(role, 'entity_id') and role.entity_id else None,
'permission_ids': role.permission_ids.ids if hasattr(role, 'permission_ids') else [],
'user_count': len(role.user_ids) if hasattr(role, 'user_ids') else 0,
}
class EntityController(http.Controller):
@http.route('/api/entities', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_entities(self, **kw):
try:
M = request.env[ENTITY_MODEL].sudo()
domain = []
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
if kw.get('type'):
domain.append(('type', '=', kw['type']))
recs = M.search(domain, order='name')
items = [_entity_to_dict(r) for r in recs]
return _json_response({
'data': items,
'items': items,
'total': len(items),
})
except Exception as e:
_logger.exception('list entities failed')
return _error_response(str(e), 500)
@http.route('/api/entities/<int:entity_id>', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def get_entity(self, entity_id, **kw):
try:
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
return _json_response({'data': _entity_to_dict(entity)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/entities', type='http', auth='public',
methods=['POST'], csrf=False)
@jwt_required
def create_entity(self, **kw):
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('Name is required', 400)
code = body.get('code', '').strip()
if not code:
code = name.upper().replace(' ', '_')[:20]
vals = {'name': name, 'code': code}
for field in ('type', 'primary_color', 'secondary_color',
'background_color', 'login_title',
'login_description', 'results_release_mode'):
if field in body:
vals[field] = body[field]
try:
entity = request.env[ENTITY_MODEL].sudo().create(vals)
return _json_response({'data': _entity_to_dict(entity)}, 201)
except Exception as e:
_logger.exception('Error creating entity')
msg = str(e)
if 'unique' in msg.lower() or 'duplicate' in msg.lower():
return _error_response(
f'An entity with code "{code}" already exists', 409)
return _error_response(msg, 500)
@http.route('/api/entities/<int:entity_id>', type='http', auth='public',
methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_entity(self, entity_id, **kw):
body = _get_json_body()
try:
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
vals = {}
for field in ('name', 'code', 'type', 'primary_color',
'secondary_color', 'background_color',
'login_title', 'login_description',
'results_release_mode', 'active'):
if field in body:
vals[field] = body[field]
if vals:
entity.write(vals)
return _json_response({'data': _entity_to_dict(entity)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/entities/<int:entity_id>', type='http', auth='public',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_entity(self, entity_id, **kw):
try:
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
entity.sudo().unlink()
return _json_response({'success': True})
except Exception as e:
msg = str(e)
if 'foreign key' in msg.lower() or 'restrict' in msg.lower():
return _error_response(
'Cannot delete: entity has linked users or roles', 409)
return _error_response(msg, 500)
@http.route('/api/entities/<int:entity_id>/roles', type='http',
auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_entity_roles(self, entity_id, **kw):
try:
roles = request.env['encoach.role'].sudo().search([
('entity_id', '=', entity_id)
])
return _json_response({
'data': [_role_to_dict(r) for r in roles],
'total': len(roles),
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/entities/<int:entity_id>/roles', type='http',
auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_entity_role(self, entity_id, **kw):
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('Role name is required', 400)
vals = {'name': name, 'entity_id': entity_id}
if body.get('permission_ids'):
vals['permission_ids'] = [(6, 0, [int(p) for p in body['permission_ids']])]
try:
role = request.env['encoach.role'].sudo().create(vals)
return _json_response({'data': _role_to_dict(role)}, 201)
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,312 @@
import json
import logging
from datetime import datetime
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
validate_token, _json_response as _base_json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
def _require_jwt():
"""Validate JWT and set the user context. Return user or error response."""
user = validate_token()
if not user:
return None, _error_response("Authentication required", status=401)
request.update_env(user=user.id)
return user, None
def _normalize_dt(val):
"""Convert ISO datetime strings (with T) to Odoo-compatible format."""
if not val or not isinstance(val, str):
return val
return val.replace('T', ' ').replace('Z', '').split('+')[0]
def _schedule_to_dict(rec):
return {
'id': rec.id,
'name': rec.name,
'exam_id': rec.exam_id.id if rec.exam_id else None,
'exam_title': rec.exam_id.title if rec.exam_id else '',
'entity_id': rec.entity_id.id if rec.entity_id else None,
'entity_name': rec.entity_id.name if rec.entity_id else '',
'start_date': rec.start_date.isoformat() if rec.start_date else None,
'end_date': rec.end_date.isoformat() if rec.end_date else None,
'state': rec.state,
'assign_mode': rec.assign_mode,
'full_length': rec.full_length,
'generate_different': rec.generate_different,
'auto_release_results': rec.auto_release_results,
'auto_start': rec.auto_start,
'official_exam': rec.official_exam,
'hide_assignee_details': rec.hide_assignee_details,
'batch_ids': rec.batch_ids.ids,
'batch_names': [b.name for b in rec.batch_ids],
'student_ids': rec.student_ids.ids,
'assignee_count': rec.assignee_count,
'completed_count': rec.completed_count,
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
}
class EncoachExamScheduleController(http.Controller):
@http.route('/api/exam-schedules', type='http', auth='none',
methods=['GET'], csrf=False)
def list_schedules(self, **kw):
try:
user, err = _require_jwt()
if err:
return err
Schedule = request.env['encoach.exam.schedule'].sudo()
domain = []
state_filter = kw.get('state')
if state_filter:
domain.append(('state', '=', state_filter))
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
total = Schedule.search_count(domain)
records = Schedule.search(domain, limit=limit, offset=offset,
order='start_date desc')
return _json_response({
'items': [_schedule_to_dict(r) for r in records],
'total': total,
})
except Exception as e:
_logger.exception('exam-schedules list failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/exam-schedules', type='http', auth='none',
methods=['POST'], csrf=False)
def create_schedule(self, **kw):
try:
user, err = _require_jwt()
if err:
return err
body = json.loads(request.httprequest.data or '{}')
name = body.get('name', '').strip()
exam_id = body.get('exam_id')
if not name or not exam_id:
return _json_response({'error': 'name and exam_id are required'}, 400)
start_date = _normalize_dt(body.get('start_date'))
end_date = _normalize_dt(body.get('end_date'))
if not start_date or not end_date:
return _json_response({'error': 'start_date and end_date are required'}, 400)
now = datetime.now()
try:
sd = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S')
except ValueError:
sd = datetime.strptime(start_date[:19], '%Y-%m-%d %H:%M:%S')
try:
ed = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S')
except ValueError:
ed = datetime.strptime(end_date[:19], '%Y-%m-%d %H:%M:%S')
if sd <= now and ed > now:
initial_state = 'active'
elif sd > now:
initial_state = 'planned'
else:
initial_state = 'past'
vals = {
'name': name,
'exam_id': int(exam_id),
'entity_id': body.get('entity_id') and int(body['entity_id']) or False,
'start_date': start_date,
'end_date': end_date,
'state': initial_state,
'assign_mode': body.get('assign_mode', 'batch'),
'full_length': body.get('full_length', True),
'generate_different': body.get('generate_different', False),
'auto_release_results': body.get('auto_release_results', False),
'auto_start': body.get('auto_start', False),
'official_exam': body.get('official_exam', False),
'hide_assignee_details': body.get('hide_assignee_details', False),
}
batch_ids = body.get('batch_ids', [])
if batch_ids:
vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])]
student_ids = body.get('student_ids', [])
if student_ids:
vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])]
rec = request.env['encoach.exam.schedule'].sudo().create(vals)
self._create_individual_assignments(rec)
return _json_response(_schedule_to_dict(rec), 201)
except Exception as e:
_logger.exception('exam-schedule create failed')
return _json_response({'error': str(e)}, 500)
def _create_individual_assignments(self, schedule):
"""Create individual assignment records for each targeted student."""
Assignment = request.env['encoach.exam.assignment'].sudo()
created_user_ids = set()
if schedule.student_ids:
for user in schedule.student_ids:
if user.id not in created_user_ids:
Assignment.create({
'exam_id': schedule.exam_id.id,
'schedule_id': schedule.id,
'student_id': user.id,
'access_start': schedule.start_date,
'access_end': schedule.end_date,
'status': 'assigned',
})
created_user_ids.add(user.id)
for batch in schedule.batch_ids:
try:
students = request.env['op.student'].sudo().search([('batch_id', '=', batch.id)])
for student in students:
user = student.user_id if hasattr(student, 'user_id') else None
if user and user.id not in created_user_ids:
Assignment.create({
'exam_id': schedule.exam_id.id,
'schedule_id': schedule.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': schedule.start_date,
'access_end': schedule.end_date,
'status': 'assigned',
})
created_user_ids.add(user.id)
except Exception:
_logger.warning('Batch student lookup failed for batch %s', batch.id)
if schedule.assign_mode == 'entity' and schedule.entity_id:
entity_users = schedule.entity_id.user_ids
for user in entity_users:
if user.id not in created_user_ids:
Assignment.create({
'exam_id': schedule.exam_id.id,
'schedule_id': schedule.id,
'student_id': user.id,
'access_start': schedule.start_date,
'access_end': schedule.end_date,
'status': 'assigned',
})
created_user_ids.add(user.id)
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
def update_schedule(self, schedule_id, **kw):
try:
user, err = _require_jwt()
if err:
return err
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
if not rec.exists():
return _json_response({'error': 'Not found'}, 404)
body = json.loads(request.httprequest.data or '{}')
vals = {}
for f in ('name', 'assign_mode',
'full_length', 'generate_different', 'auto_release_results',
'auto_start', 'official_exam', 'hide_assignee_details'):
if f in body:
vals[f] = body[f]
if 'start_date' in body:
vals['start_date'] = _normalize_dt(body['start_date'])
if 'end_date' in body:
vals['end_date'] = _normalize_dt(body['end_date'])
if 'entity_id' in body:
vals['entity_id'] = body['entity_id'] and int(body['entity_id']) or False
if 'batch_ids' in body:
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
if 'student_ids' in body:
vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])]
if 'state' in body:
vals['state'] = body['state']
if vals:
rec.write(vals)
return _json_response(_schedule_to_dict(rec))
except Exception as e:
_logger.exception('exam-schedule update failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
def delete_schedule(self, schedule_id, **kw):
try:
user, err = _require_jwt()
if err:
return err
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
if not rec.exists():
return _json_response({'error': 'Not found'}, 404)
rec.assignment_ids.unlink()
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('exam-schedule delete failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/exam-schedules/<int:schedule_id>/archive', type='http', auth='none',
methods=['POST'], csrf=False)
def archive_schedule(self, schedule_id, **kw):
try:
user, err = _require_jwt()
if err:
return err
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
if not rec.exists():
return _json_response({'error': 'Not found'}, 404)
rec.write({'state': 'archived'})
return _json_response(_schedule_to_dict(rec))
except Exception as e:
_logger.exception('exam-schedule archive failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/student/my-exams', type='http', auth='none',
methods=['GET'], csrf=False)
def student_my_exams(self, **kw):
"""Return exam assignments for the current student user."""
try:
user, err = _require_jwt()
if err:
return err
Assignment = request.env['encoach.exam.assignment'].sudo()
assignments = Assignment.search([
('student_id', '=', user.id),
('status', 'in', ['assigned', 'started']),
], order='access_start asc')
result = []
for a in assignments:
schedule = a.schedule_id
state = schedule.state if schedule else 'active'
result.append({
'id': a.id,
'exam_id': a.exam_id.id,
'exam_title': a.exam_id.title if a.exam_id else '',
'schedule_name': schedule.name if schedule else '',
'start_date': a.access_start.isoformat() if a.access_start else None,
'end_date': a.access_end.isoformat() if a.access_end else None,
'status': a.status,
'schedule_state': state,
'auto_start': schedule.auto_start if schedule else False,
'can_start': state == 'active' and a.status == 'assigned',
})
return _json_response({'items': result})
except Exception as e:
_logger.exception('student my-exams failed')
return _json_response({'error': str(e)}, 500)

View File

@@ -3,24 +3,17 @@ import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _json_body():
try:
return json.loads(request.httprequest.data or '{}')
except Exception:
return {}
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
class ExamStructureController(http.Controller):
@http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False)
@http.route('/api/exam-structures', type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_structures(self, **kw):
domain = [('active', '=', True)]
entity_id = kw.get('entity_id')
@@ -29,8 +22,9 @@ class ExamStructureController(http.Controller):
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
total = request.env['encoach.exam.structure'].search_count(domain)
records = request.env['encoach.exam.structure'].sudo().search(
domain, limit=limit, offset=offset, order='create_date desc')
total = request.env['encoach.exam.structure'].sudo().search_count(domain)
items = []
for r in records:
@@ -52,12 +46,13 @@ class ExamStructureController(http.Controller):
return _json_response({'items': items, 'total': total})
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
@http.route('/api/exam-structures', type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_structure(self, **kw):
body = _json_body()
body = _get_json_body()
name = body.get('name')
if not name:
return _json_response({'error': 'name is required'}, status=400)
return _error_response('name is required', 400)
vals = {
'name': name,
@@ -69,19 +64,61 @@ class ExamStructureController(http.Controller):
if entity_id:
vals['entity_id'] = int(entity_id)
record = request.env['encoach.exam.structure'].create(vals)
record = request.env['encoach.exam.structure'].sudo().create(vals)
return _json_response({
'id': record.id,
'name': record.name,
'entity_id': record.entity_id.id if record.entity_id else None,
'industry': record.industry or '',
'modules': json.loads(record.modules) if record.modules else [],
'config': json.loads(record.config) if record.config else {},
})
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
def delete_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].browse(structure_id)
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
if not record.exists():
return _json_response({'error': 'Structure not found'}, status=404)
return _error_response('Structure not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'industry' in body:
vals['industry'] = body['industry']
if 'modules' in body:
vals['modules'] = json.dumps(body['modules'])
if 'config' in body:
vals['config'] = json.dumps(body['config'])
if 'entity_id' in body:
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
if vals:
record.write(vals)
modules = []
if record.modules:
try:
modules = json.loads(record.modules)
except Exception:
modules = []
return _json_response({
'id': record.id,
'name': record.name,
'entity_id': record.entity_id.id if record.entity_id else None,
'entity_name': record.entity_id.name if record.entity_id else None,
'industry': record.industry or '',
'modules': modules,
'config': json.loads(record.config) if record.config else {},
})
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
if not record.exists():
return _error_response('Structure not found', 404)
record.unlink()
return _json_response({'success': True})

View File

@@ -0,0 +1,256 @@
"""Human-in-the-loop review workflow for AI-generated exams.
When an exam is created via the AI pipeline and fails the automated quality
gate (QualityChecker + IeltsValidator), it transitions to ``pending_review``
instead of being published directly (see ADR 0004 and P1.1 in the hardening
release). This controller exposes the admin-facing endpoints used by the
review queue UI to inspect, approve, or reject those exams.
Endpoints:
* ``GET /api/exam/review/queue`` — paginated list of pending exams
* ``GET /api/exam/review/<exam_id>`` — detail with per-question quality data
* ``POST /api/exam/review/<exam_id>/approve`` — publish (optional notes)
* ``POST /api/exam/review/<exam_id>/reject`` — back to draft (notes required)
"""
import json
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _question_to_review_dict(q):
"""Compact projection of a question for the review UI."""
try:
report = json.loads(q.quality_report) if q.quality_report else None
except (TypeError, ValueError):
report = {'raw': q.quality_report}
return {
'id': q.id,
'skill': q.skill,
'question_type': q.question_type,
'difficulty': q.difficulty,
'stem': q.stem or '',
'marks': q.marks or 0.0,
'ai_generated': bool(q.ai_generated),
'ielts_certified': bool(q.ielts_certified),
'format_validated': bool(q.format_validated),
'ai_model_used': q.ai_model_used or '',
'ai_prompt_hash': q.ai_prompt_hash or '',
'quality_score': q.quality_score or 0.0,
'quality_report': report,
}
def _review_summary(exam):
"""Aggregate quality stats across every question in the exam."""
questions = exam.section_ids.mapped('question_ids')
total = len(questions)
if total == 0:
return {
'question_count': 0,
'avg_quality_score': 0.0,
'min_quality_score': 0.0,
'failing_count': 0,
'ai_generated_count': 0,
}
scores = [q.quality_score or 0.0 for q in questions]
failing_threshold = 0.7 # configurable later via ir.config_parameter
return {
'question_count': total,
'avg_quality_score': round(sum(scores) / total, 3),
'min_quality_score': round(min(scores), 3),
'failing_count': sum(1 for s in scores if s < failing_threshold),
'ai_generated_count': sum(1 for q in questions if q.ai_generated),
}
def _exam_to_review_dict(exam, *, include_questions=False):
data = {
'id': exam.id,
'title': exam.title,
'status': exam.status,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'subject_name': exam.subject_id.name if exam.subject_id else '',
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'teacher_name': exam.teacher_id.name if exam.teacher_id else '',
'grading_system': exam.grading_system,
'total_time_min': exam.total_time_min or 0,
'total_marks': exam.total_marks or 0.0,
'reviewed_by_id': exam.reviewed_by_id.id if exam.reviewed_by_id else None,
'reviewed_by_name': exam.reviewed_by_id.name if exam.reviewed_by_id else '',
'reviewed_at': (
fields.Datetime.to_string(exam.reviewed_at) if exam.reviewed_at else None
),
'review_notes': exam.review_notes or '',
'summary': _review_summary(exam),
}
if include_questions:
data['sections'] = []
for sec in exam.section_ids:
data['sections'].append({
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'sequence': sec.sequence,
'questions': [
_question_to_review_dict(q) for q in sec.question_ids
],
})
return data
class EncoachExamReviewController(http.Controller):
"""Admin review queue for AI-generated exams gated by the quality checker."""
# ------------------------------------------------------------------
# GET /api/exam/review/queue
# ------------------------------------------------------------------
@http.route('/api/exam/review/queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def queue(self, **kw):
try:
Exam = request.env['encoach.exam.custom'].sudo()
# Default to pending_review but allow callers to inspect history too.
status = (kw.get('status') or 'pending_review').strip()
domain = [('status', '=', status)] if status else []
search = (kw.get('search') or '').strip()
if search:
domain.append(('title', 'ilike', search))
subject_id = kw.get('subject_id')
if subject_id:
try:
domain.append(('subject_id', '=', int(subject_id)))
except (TypeError, ValueError):
pass
total = Exam.search_count(domain)
page, per_page, offset = _paginate(kw)
exams = Exam.search(
domain, limit=per_page, offset=offset, order='id desc',
)
items = [_exam_to_review_dict(e) for e in exams]
return _json_response({
'items': items,
'data': items,
'total': total,
'page': page,
'size': per_page,
})
except Exception as e:
_logger.exception('exam review queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/review/<exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def detail(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(
_exam_to_review_dict(exam, include_questions=True)
)
except Exception as e:
_logger.exception('exam review detail failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/review/<exam_id>/approve
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'pending_review':
return _error_response(
"Only exams in 'pending_review' can be approved "
f"(current status: {exam.status})",
400,
)
body = _get_json_body() or {}
notes = (body.get('notes') or '').strip() or False
with request.env.cr.savepoint():
exam.write({
'status': 'published',
'reviewed_by_id': request.env.user.id,
'reviewed_at': fields.Datetime.now(),
'review_notes': notes,
})
_logger.info(
'exam %s approved by user %s', exam.id, request.env.user.id,
)
return _json_response(
_exam_to_review_dict(exam, include_questions=False)
)
except Exception as e:
_logger.exception('exam review approve failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/review/<exam_id>/reject
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'pending_review':
return _error_response(
"Only exams in 'pending_review' can be rejected "
f"(current status: {exam.status})",
400,
)
body = _get_json_body() or {}
notes = (body.get('notes') or '').strip()
# Require notes on rejection — the author needs actionable feedback.
if not notes:
return _error_response(
'Review notes are required when rejecting an exam.', 400,
)
with request.env.cr.savepoint():
exam.write({
'status': 'draft',
'reviewed_by_id': request.env.user.id,
'reviewed_at': fields.Datetime.now(),
'review_notes': notes,
})
_logger.info(
'exam %s rejected by user %s', exam.id, request.env.user.id,
)
return _json_response(
_exam_to_review_dict(exam, include_questions=False)
)
except Exception as e:
_logger.exception('exam review reject failed')
return _error_response(str(e), 500)

View File

@@ -3,14 +3,13 @@ import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
def _rubric_to_dict(rec):
criteria_text = rec.criteria or ''
criteria_count = 0
@@ -26,6 +25,15 @@ def _rubric_to_dict(rec):
except (json.JSONDecodeError, ValueError):
criteria_count = len([l for l in criteria_text.split('\n') if l.strip()])
levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
if rec.levels:
try:
parsed_levels = json.loads(rec.levels)
if isinstance(parsed_levels, list) and parsed_levels:
levels = parsed_levels
except (json.JSONDecodeError, ValueError):
pass
return {
'id': rec.id,
'name': rec.name,
@@ -33,15 +41,16 @@ def _rubric_to_dict(rec):
'exam_type': rec.exam_type or '',
'criteria': criteria_count or 1,
'criteria_text': criteria_text,
'levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'],
'levels': levels,
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
}
class EncoachRubricController(http.Controller):
@http.route('/api/rubrics', type='http', auth='user',
@http.route('/api/rubrics', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_rubrics(self, **kw):
try:
Rubric = request.env['encoach.rubric'].sudo()
@@ -58,14 +67,15 @@ class EncoachRubricController(http.Controller):
_logger.exception('rubrics list failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubrics', type='http', auth='user',
@http.route('/api/rubrics', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_rubric(self, **kw):
try:
body = json.loads(request.httprequest.data or '{}')
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _json_response({'error': 'name is required'}, 400)
return _error_response('name is required', 400)
vals = {
'name': name,
@@ -73,8 +83,140 @@ class EncoachRubricController(http.Controller):
'criteria': body.get('criteria', ''),
'exam_type': body.get('exam_type', 'academic'),
}
rec = Rubric = request.env['encoach.rubric'].sudo().create(vals)
if 'levels' in body:
vals['levels'] = json.dumps(body['levels'])
rec = request.env['encoach.rubric'].sudo().create(vals)
return _json_response(_rubric_to_dict(rec), 201)
except Exception as e:
_logger.exception('rubric create failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_rubric(self, rubric_id, **kw):
try:
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
if not rec.exists():
return _error_response('Rubric not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'skill' in body:
vals['skill'] = body['skill']
if 'criteria' in body:
vals['criteria'] = body['criteria']
if 'exam_type' in body:
vals['exam_type'] = body['exam_type']
if 'levels' in body:
vals['levels'] = json.dumps(body['levels'])
if vals:
rec.write(vals)
return _json_response(_rubric_to_dict(rec))
except Exception as e:
_logger.exception('rubric update failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_rubric(self, rubric_id, **kw):
try:
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
if not rec.exists():
return _error_response('Rubric not found', 404)
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('rubric delete failed')
return _json_response({'error': str(e)}, 500)
def _group_to_dict(rec):
return {
'id': rec.id,
'name': rec.name,
'rubric_ids': rec.rubric_ids.ids,
'rubric_names': [r.name for r in rec.rubric_ids],
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
}
class EncoachRubricGroupController(http.Controller):
@http.route('/api/rubric-groups', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_rubric_groups(self, **kw):
try:
Group = request.env['encoach.rubric.group'].sudo()
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
records = Group.search([], limit=limit, offset=offset,
order='create_date desc')
total = Group.search_count([])
return _json_response({
'items': [_group_to_dict(r) for r in records],
'total': total,
})
except Exception as e:
_logger.exception('rubric-groups list failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubric-groups', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_rubric_group(self, **kw):
try:
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('name is required', 400)
rubric_ids = body.get('rubric_ids', [])
rec = request.env['encoach.rubric.group'].sudo().create({
'name': name,
'rubric_ids': [(6, 0, rubric_ids)],
})
return _json_response(_group_to_dict(rec), 201)
except Exception as e:
_logger.exception('rubric-group create failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_rubric_group(self, group_id, **kw):
try:
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
if not rec.exists():
return _error_response('Group not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'rubric_ids' in body:
vals['rubric_ids'] = [(6, 0, body['rubric_ids'])]
if vals:
rec.write(vals)
return _json_response(_group_to_dict(rec))
except Exception as e:
_logger.exception('rubric-group update failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_rubric_group(self, group_id, **kw):
try:
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
if not rec.exists():
return _error_response('Group not found', 404)
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('rubric-group delete failed')
return _json_response({'error': str(e)}, 500)

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_exam_schedule_lifecycle" model="ir.cron">
<field name="name">Exam Schedule Lifecycle</field>
<field name="model_id" ref="model_encoach_exam_schedule"/>
<field name="state">code</field>
<field name="code">model.update_lifecycle_states()</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -1,4 +1,5 @@
from . import rubric
from . import rubric_group
from . import exam_template
from . import passage
from . import audio_file
@@ -8,4 +9,6 @@ from . import speaking_card
from . import exam_custom
from . import exam_custom_section
from . import exam_assignment
from . import exam_schedule
from . import exam_structure
from . import approval

View File

@@ -0,0 +1,95 @@
"""Approval workflow models.
Powers the admin "Approval Config" page (`/admin/approval-config`) and the
per-record approval requests exposed by `ApprovalWorkflowController`.
Prior to this module the controller hand-rolled raw SQL against tables that
were never created. These ORM models now own the schema so Odoo can create
the tables on install/update and the controller can use a standard API.
"""
from odoo import api, fields, models
STATUS_SELECTION = [
('draft', 'Draft'),
('active', 'Active'),
('archived', 'Archived'),
]
REQUEST_STATE = [
('draft', 'Draft'),
('in_progress', 'In Progress'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('bypassed', 'Bypassed'),
]
STAGE_STATUS = [
('pending', 'Pending'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('skipped', 'Skipped'),
]
class EncoachApprovalWorkflow(models.Model):
_name = 'encoach.approval.workflow'
_description = 'Approval Workflow'
_order = 'create_date desc'
name = fields.Char(required=True)
type = fields.Char(default='custom',
help='Categorical tag e.g. leave, purchase, content, custom.')
status = fields.Selection(STATUS_SELECTION, default='draft', required=True,
help='Draft = not yet routable. Active = available to new requests.')
allow_bypass = fields.Boolean(
default=False,
help='If true, a bypass reason allows the workflow to be skipped.')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
stage_ids = fields.One2many('encoach.approval.stage', 'workflow_id',
copy=True)
request_ids = fields.One2many('encoach.approval.request', 'workflow_id')
stage_count = fields.Integer(compute='_compute_counts')
request_count = fields.Integer(compute='_compute_counts')
@api.depends('stage_ids', 'request_ids')
def _compute_counts(self):
for rec in self:
rec.stage_count = len(rec.stage_ids)
rec.request_count = len(rec.request_ids)
class EncoachApprovalStage(models.Model):
_name = 'encoach.approval.stage'
_description = 'Approval Workflow Stage'
_order = 'sequence, id'
workflow_id = fields.Many2one('encoach.approval.workflow',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10, required=True,
help='Stored order; the admin UI speaks `order`.')
approver_id = fields.Many2one('res.users', required=True, ondelete='restrict')
max_days = fields.Integer(default=3,
help='SLA before auto-escalation can fire.')
auto_escalate = fields.Boolean(default=False)
notification_email = fields.Char(
help='Optional CC address; falls back to approver_id.email otherwise.')
status = fields.Selection(STAGE_STATUS, default='pending')
comment = fields.Text()
acted_at = fields.Datetime()
class EncoachApprovalRequest(models.Model):
_name = 'encoach.approval.request'
_description = 'Approval Request'
_order = 'create_date desc'
workflow_id = fields.Many2one('encoach.approval.workflow',
required=True, ondelete='cascade', index=True)
res_model = fields.Char(help='Optional target model (e.g. op.student.leave).')
res_id = fields.Integer(help='Optional target record id inside res_model.')
state = fields.Selection(REQUEST_STATE, default='draft', required=True)
requester_id = fields.Many2one('res.users', ondelete='set null')
current_stage_id = fields.Many2one('encoach.approval.stage', ondelete='set null')
bypass_reason = fields.Text()
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -6,6 +6,7 @@ class EncoachExamAssignment(models.Model):
_description = 'Exam Assignment'
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
schedule_id = fields.Many2one('encoach.exam.schedule', ondelete='cascade')
student_id = fields.Many2one('res.users', ondelete='cascade')
batch_id = fields.Many2one('op.batch', ondelete='set null')
access_start = fields.Datetime()

View File

@@ -6,13 +6,32 @@ class EncoachExamCustom(models.Model):
_description = 'Custom Exam'
title = fields.Char(size=200, required=True)
label = fields.Char(size=100)
exam_mode = fields.Selection([
('official', 'Official'),
('practice', 'Practice'),
], default='official')
template_id = fields.Many2one('encoach.exam.template', ondelete='set null')
structure_id = fields.Many2one('encoach.exam.structure', ondelete='set null')
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
teacher_id = fields.Many2one('res.users', ondelete='set null')
rubric_id = fields.Many2one('encoach.rubric', ondelete='set null')
approval_workflow_id = fields.Integer()
description = fields.Text()
total_time_min = fields.Integer()
total_marks = fields.Float()
pass_threshold = fields.Float()
grading_system = fields.Selection([
('ielts', 'IELTS Band'),
('percentage', 'Percentage'),
('pass_fail', 'Pass / Fail'),
('cefr', 'CEFR Level'),
], default='ielts')
access_type = fields.Selection([
('private', 'Private'),
('public', 'Public'),
], default='private')
results_release_mode = fields.Selection([
('auto', 'Auto'),
('manual_approval', 'Manual Approval'),
@@ -20,7 +39,22 @@ class EncoachExamCustom(models.Model):
randomize_questions = fields.Boolean(default=False)
status = fields.Selection([
('draft', 'Draft'),
('pending_review', 'Pending Review'),
('published', 'Published'),
('archived', 'Archived'),
], default='draft', required=True)
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')
# Human-in-the-loop review audit trail. Populated by the
# /api/exam/review/<id>/(approve|reject) endpoints so we know who signed
# off on an AI-generated exam and what their reasoning was.
reviewed_by_id = fields.Many2one(
'res.users', ondelete='set null',
string='Reviewed By',
readonly=True,
)
reviewed_at = fields.Datetime(string='Reviewed At', readonly=True)
review_notes = fields.Text(
string='Review Notes',
help='Approver or rejecter comments. Required when rejecting back to draft.',
)

View File

@@ -1,3 +1,4 @@
import json
from odoo import models, fields
@@ -8,14 +9,19 @@ class EncoachExamCustomSection(models.Model):
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
title = fields.Char(size=200, required=True)
skill = fields.Char(size=100)
difficulty = fields.Char(size=50)
question_count = fields.Integer()
time_limit_min = fields.Integer()
total_marks = fields.Float()
scoring_method = fields.Selection([
('auto', 'Auto'),
('rubric', 'Rubric'),
('mixed', 'Mixed'),
], default='auto')
sequence = fields.Integer(default=10)
passage_text = fields.Text()
instructions_text = fields.Text()
content_json = fields.Text(help='JSON blob for tasks/parts/passages/sections config')
question_ids = fields.Many2many(
'encoach.question',
'exam_custom_section_question_rel',

View File

@@ -0,0 +1,68 @@
from odoo import models, fields, api
from datetime import datetime
class EncoachExamSchedule(models.Model):
_name = 'encoach.exam.schedule'
_description = 'Exam Schedule / Assignment Group'
_order = 'create_date desc'
name = fields.Char(required=True, size=200)
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
start_date = fields.Datetime(required=True)
end_date = fields.Datetime(required=True)
full_length = fields.Boolean(default=True)
generate_different = fields.Boolean(default=False)
auto_release_results = fields.Boolean(default=False)
auto_start = fields.Boolean(default=False)
official_exam = fields.Boolean(default=False)
hide_assignee_details = fields.Boolean(default=False)
assign_mode = fields.Selection([
('entity', 'Entire Entity'),
('batch', 'Class / Batch'),
('individual', 'Individual Students'),
], default='batch', required=True)
batch_ids = fields.Many2many('op.batch', string='Classes')
student_ids = fields.Many2many('res.users', 'exam_schedule_student_rel',
'schedule_id', 'user_id', string='Students')
state = fields.Selection([
('planned', 'Planned'),
('active', 'Active'),
('past', 'Past'),
('start_expired', 'Start Expired'),
('archived', 'Archived'),
], default='planned', required=True, index=True)
assignment_ids = fields.One2many('encoach.exam.assignment', 'schedule_id')
assignee_count = fields.Integer(compute='_compute_counts', store=True)
completed_count = fields.Integer(compute='_compute_counts', store=True)
@api.depends('assignment_ids', 'assignment_ids.status')
def _compute_counts(self):
for rec in self:
assignments = rec.assignment_ids
rec.assignee_count = len(assignments)
rec.completed_count = len(assignments.filtered(lambda a: a.status == 'completed'))
def update_lifecycle_states(self):
"""Cron job: transition schedules based on current time."""
now = datetime.now()
planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '>', now)])
planned.write({'state': 'active'})
expired_planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '<=', now)])
expired_planned.write({'state': 'start_expired'})
active_ended = self.search([('state', '=', 'active'), ('end_date', '<=', now)])
active_ended.write({'state': 'past'})
for rec in active_ended:
rec.assignment_ids.filtered(
lambda a: a.status in ('assigned', 'started')
).write({'status': 'expired'})

View File

@@ -1,4 +1,8 @@
from odoo import models, fields
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class EncoachQuestion(models.Model):
@@ -67,3 +71,55 @@ class EncoachQuestion(models.Model):
format_validated = fields.Boolean(default=False)
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
topic_id = fields.Many2one('encoach.topic', ondelete='set null')
ai_model_used = fields.Char(
string='AI Model',
help='LLM model that produced this question (e.g. gpt-4o-mini).',
index=True,
)
ai_prompt_hash = fields.Char(
string='Prompt Hash',
help='SHA-256 hex digest of the rendered prompt used to generate this question.',
index=True,
)
ai_log_id = fields.Integer(
string='AI Generation Log ID',
help='Row id in encoach.ai.generation.log (soft ref — no FK to avoid cross-module coupling).',
index=True,
)
ai_generated_at = fields.Datetime(string='AI Generated At')
quality_score = fields.Float(
string='Quality Score',
help='Aggregate quality score from automated checkers (0-1).',
)
quality_report = fields.Text(
string='Quality Report (JSON)',
help='JSON dump of the last QualityChecker + IeltsValidator run.',
)
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
for name, ddl in (
(
'encoach_question_skill_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_skill_status_idx "
"ON encoach_question (skill, status)",
),
(
'encoach_question_subject_difficulty_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_subject_difficulty_idx "
"ON encoach_question (subject_id, difficulty) WHERE subject_id IS NOT NULL",
),
(
'encoach_question_ai_prompt_hash_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_ai_prompt_hash_idx "
"ON encoach_question (ai_prompt_hash) WHERE ai_prompt_hash IS NOT NULL",
),
):
try:
cr.execute(ddl)
except Exception:
_logger.warning("could not create index %s", name, exc_info=True)
return res

View File

@@ -11,6 +11,7 @@ class EncoachRubric(models.Model):
('speaking', 'Speaking'),
], required=True)
criteria = fields.Text(required=True)
levels = fields.Text(help='JSON list of CEFR levels, e.g. ["A1","A2","B1","B2","C1","C2"]')
exam_type = fields.Selection([
('academic', 'Academic'),
('general_training', 'General Training'),

View File

@@ -0,0 +1,15 @@
from odoo import models, fields
class EncoachRubricGroup(models.Model):
_name = 'encoach.rubric.group'
_description = 'Rubric Group'
name = fields.Char(size=200, required=True)
rubric_ids = fields.Many2many(
'encoach.rubric',
'encoach_rubric_group_rel',
'group_id',
'rubric_id',
string='Rubrics',
)

View File

@@ -6,7 +6,12 @@ access_encoach_question_user,encoach.question.user,model_encoach_question,base.g
access_encoach_writing_prompt_user,encoach.writing.prompt.user,model_encoach_writing_prompt,base.group_user,1,1,1,1
access_encoach_speaking_card_user,encoach.speaking.card.user,model_encoach_speaking_card,base.group_user,1,1,1,1
access_encoach_rubric_user,encoach.rubric.user,model_encoach_rubric,base.group_user,1,1,1,1
access_encoach_rubric_group_user,encoach.rubric.group.user,model_encoach_rubric_group,base.group_user,1,1,1,1
access_encoach_exam_custom_user,encoach.exam.custom.user,model_encoach_exam_custom,base.group_user,1,1,1,1
access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_encoach_exam_custom_section,base.group_user,1,1,1,1
access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1
access_encoach_exam_schedule_user,encoach.exam.schedule.user,model_encoach_exam_schedule,base.group_user,1,1,1,1
access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
access_encoach_approval_workflow_user,encoach.approval.workflow.user,model_encoach_approval_workflow,base.group_user,1,1,1,1
access_encoach_approval_stage_user,encoach.approval.stage.user,model_encoach_approval_stage,base.group_user,1,1,1,1
access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_encoach_writing_prompt_user encoach.writing.prompt.user model_encoach_writing_prompt base.group_user 1 1 1 1
7 access_encoach_speaking_card_user encoach.speaking.card.user model_encoach_speaking_card base.group_user 1 1 1 1
8 access_encoach_rubric_user encoach.rubric.user model_encoach_rubric base.group_user 1 1 1 1
9 access_encoach_rubric_group_user encoach.rubric.group.user model_encoach_rubric_group base.group_user 1 1 1 1
10 access_encoach_exam_custom_user encoach.exam.custom.user model_encoach_exam_custom base.group_user 1 1 1 1
11 access_encoach_exam_custom_section_user encoach.exam.custom.section.user model_encoach_exam_custom_section base.group_user 1 1 1 1
12 access_encoach_exam_assignment_user encoach.exam.assignment.user model_encoach_exam_assignment base.group_user 1 1 1 1
13 access_encoach_exam_schedule_user encoach.exam.schedule.user model_encoach_exam_schedule base.group_user 1 1 1 1
14 access_encoach_exam_structure_user encoach.exam.structure.user model_encoach_exam_structure base.group_user 1 1 1 1
15 access_encoach_approval_workflow_user encoach.approval.workflow.user model_encoach_approval_workflow base.group_user 1 1 1 1
16 access_encoach_approval_stage_user encoach.approval.stage.user model_encoach_approval_stage base.group_user 1 1 1 1
17 access_encoach_approval_request_user encoach.approval.request.user model_encoach_approval_request base.group_user 1 1 1 1

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -0,0 +1,33 @@
{
'name': 'EnCoach LMS API',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'REST API gateway for all LMS frontend pages',
'author': 'EnCoach',
'depends': [
'base',
'mail',
'openeducat_core',
'openeducat_classroom',
'openeducat_timetable',
'openeducat_attendance',
'openeducat_activity',
'openeducat_admission',
'openeducat_assignment',
'openeducat_exam',
'openeducat_facility',
'openeducat_fees',
'openeducat_library',
'encoach_core',
'encoach_api',
'encoach_ai',
'encoach_taxonomy',
'encoach_resources',
],
'data': [
'security/ir.model.access.csv',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,28 @@
from . import lms_core
from . import academic
from . import timetable
from . import attendance
from . import grades
from . import admissions
from . import fees
from . import library
from . import activities
from . import facilities
from . import lessons
from . import student_leave
from . import student_progress
from . import communication
from . import institutional_exams
from . import resources
from . import users_roles
from . import classrooms
from . import courseware
from . import notifications
from . import faq
from . import stats
from . import tickets
from . import payments
from . import paymob
from . import platform_settings
from . import training
from . import reports

View File

@@ -0,0 +1,364 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_year(r):
terms = []
if hasattr(r, 'academic_term_ids'):
for t in r.academic_term_ids:
terms.append(_ser_term(t))
return {
'id': r.id,
'name': r.name or '',
'start_date': str(r.start_date) if r.start_date else '',
'end_date': str(r.end_date) if r.end_date else '',
'term_structure': getattr(r, 'term_structure', 'others') or 'others',
'terms': terms,
}
def _ser_term(t):
return {
'id': t.id,
'name': t.name or '',
'term_start_date': str(t.term_start_date) if hasattr(t, 'term_start_date') and t.term_start_date else str(t.start_date) if hasattr(t, 'start_date') and t.start_date else '',
'term_end_date': str(t.term_end_date) if hasattr(t, 'term_end_date') and t.term_end_date else str(t.end_date) if hasattr(t, 'end_date') and t.end_date else '',
'academic_year_id': t.academic_year_id.id if hasattr(t, 'academic_year_id') and t.academic_year_id else 0,
'academic_year_name': t.academic_year_id.name if hasattr(t, 'academic_year_id') and t.academic_year_id else '',
'parent_term_id': t.parent_id.id if hasattr(t, 'parent_id') and t.parent_id else None,
'parent_term_name': t.parent_id.name if hasattr(t, 'parent_id') and t.parent_id else None,
}
def _ser_dept(d):
env = d.env
course_count = 0
faculty_count = 0
try:
if 'op.course' in env:
Course = env['op.course'].sudo()
if 'department_id' in Course._fields:
course_count = Course.search_count([('department_id', '=', d.id)])
if 'op.faculty' in env:
Faculty = env['op.faculty'].sudo()
if 'department_id' in Faculty._fields:
faculty_count = Faculty.search_count([('department_id', '=', d.id)])
elif 'main_department_id' in Faculty._fields:
faculty_count = Faculty.search_count([('main_department_id', '=', d.id)])
except Exception:
pass
return {
'id': d.id,
'name': d.name or '',
'code': d.code or '' if hasattr(d, 'code') else '',
'parent_id': d.parent_id.id if hasattr(d, 'parent_id') and d.parent_id else None,
'parent_name': d.parent_id.name if hasattr(d, 'parent_id') and d.parent_id else None,
'course_count': course_count,
'faculty_count': faculty_count,
}
class AcademicController(http.Controller):
# ── Academic Years ───────────────────────────────────────────────
@http.route('/api/academic-years', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_years(self, **kw):
try:
M = request.env['op.academic.year'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
return _json_response({'items': [_ser_year(r) for r in recs], 'data': [_ser_year(r) for r in recs], 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_years')
return _error_response(str(e), 500)
@http.route('/api/academic-years/<int:yid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_year(self, yid, **kw):
try:
rec = request.env['op.academic.year'].sudo().browse(yid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_year(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/academic-years', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_year(self, **kw):
try:
body = _get_json_body()
Year = request.env['op.academic.year'].sudo()
vals = {
'name': body.get('name', ''),
'start_date': body.get('start_date'),
'end_date': body.get('end_date'),
}
if body.get('term_structure') and 'term_structure' in Year._fields:
vals['term_structure'] = body['term_structure']
rec = Year.create(vals)
return _json_response(_ser_year(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/academic-years/<int:yid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_year(self, yid, **kw):
try:
rec = request.env['op.academic.year'].sudo().browse(yid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'start_date', 'end_date'):
if k in body:
vals[k] = body[k]
if 'term_structure' in body and 'term_structure' in rec._fields:
vals['term_structure'] = body['term_structure']
if vals:
rec.write(vals)
return _json_response(_ser_year(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/academic-years/<int:yid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_year(self, yid, **kw):
try:
rec = request.env['op.academic.year'].sudo().browse(yid)
if rec.exists():
# Unlink dependent terms first to avoid RESTRICT FK violation.
if hasattr(rec, 'academic_term_ids') and rec.academic_term_ids:
rec.academic_term_ids.unlink()
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_year')
return _error_response(str(e), 500)
@http.route('/api/academic-years/<int:yid>/generate-terms', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def generate_terms(self, yid, **kw):
"""Create a set of op.academic.term records spanning the year.
The number of terms is dictated by the year's `term_structure`:
two_sem → 2 semesters
two_sem_qua → 2 semesters + 4 quarters (nested under each sem)
three_sem → 3 semesters
four_Quarter → 4 quarters
final_year → 1 single term
others → 2 semesters (default fallback)
"""
try:
from datetime import timedelta
year = request.env['op.academic.year'].sudo().browse(yid)
if not year.exists():
return _error_response('Not found', 404)
start = year.start_date
end = year.end_date
if not start or not end:
return _error_response('Year has no dates', 400)
total_days = max(1, (end - start).days)
structure = getattr(year, 'term_structure', 'two_sem') or 'two_sem'
body = _get_json_body() or {}
if body.get('replace'):
for existing in year.academic_term_ids:
existing.sudo().unlink()
Term = request.env['op.academic.term'].sudo()
def _split(n, label):
chunk = total_days // n
out = []
for i in range(n):
s = start + timedelta(days=i * chunk)
e = end if i == n - 1 else start + timedelta(days=(i + 1) * chunk - 1)
out.append(Term.create({
'name': f'{year.name} - {label} {i + 1}',
'term_start_date': str(s),
'term_end_date': str(e),
'academic_year_id': year.id,
}))
return out
created = []
if structure == 'two_sem':
created = _split(2, 'Semester')
elif structure == 'three_sem':
created = _split(3, 'Semester')
elif structure == 'four_Quarter':
created = _split(4, 'Quarter')
elif structure == 'final_year':
created = [Term.create({
'name': f'{year.name} - Final Year',
'term_start_date': str(start),
'term_end_date': str(end),
'academic_year_id': year.id,
})]
elif structure == 'two_sem_qua':
sems = _split(2, 'Semester')
quarters = []
for parent_idx, parent in enumerate(sems):
s = parent.term_start_date
e = parent.term_end_date
span = max(1, (e - s).days)
half = span // 2
q1 = Term.create({
'name': f'{year.name} - Q{parent_idx * 2 + 1}',
'term_start_date': str(s),
'term_end_date': str(s + timedelta(days=half)),
'academic_year_id': year.id,
})
q2 = Term.create({
'name': f'{year.name} - Q{parent_idx * 2 + 2}',
'term_start_date': str(s + timedelta(days=half + 1)),
'term_end_date': str(e),
'academic_year_id': year.id,
})
if 'parent_id' in Term._fields:
q1.write({'parent_id': parent.id})
q2.write({'parent_id': parent.id})
quarters += [q1, q2]
created = sems + quarters
else:
created = _split(2, 'Semester')
year.invalidate_recordset()
return _json_response([_ser_term(t) for t in created])
except Exception as e:
_logger.exception('generate_terms')
return _error_response(str(e), 500)
# ── Academic Terms ───────────────────────────────────────────────
@http.route('/api/academic-terms', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_terms(self, **kw):
try:
M = request.env['op.academic.term'].sudo()
domain = []
if kw.get('year_id'):
domain.append(('academic_year_id', '=', int(kw['year_id'])))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
return _json_response({'items': [_ser_term(r) for r in recs], 'data': [_ser_term(r) for r in recs], 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/academic-terms', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_term(self, **kw):
try:
body = _get_json_body()
vals = {
'name': body.get('name', ''),
'term_start_date': body.get('term_start_date'),
'term_end_date': body.get('term_end_date'),
}
if body.get('academic_year_id'):
vals['academic_year_id'] = int(body['academic_year_id'])
rec = request.env['op.academic.term'].sudo().create(vals)
return _json_response(_ser_term(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/academic-terms/<int:tid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_term(self, tid, **kw):
try:
rec = request.env['op.academic.term'].sudo().browse(tid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'term_start_date', 'term_end_date'):
if k in body:
vals[k] = body[k]
if 'academic_year_id' in body:
vals['academic_year_id'] = int(body['academic_year_id'])
if vals:
rec.write(vals)
return _json_response(_ser_term(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/academic-terms/<int:tid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_term(self, tid, **kw):
try:
rec = request.env['op.academic.term'].sudo().browse(tid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Departments ──────────────────────────────────────────────────
@http.route('/api/departments', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_depts(self, **kw):
try:
M = request.env['op.department'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
return _json_response({'items': [_ser_dept(r) for r in recs], 'data': [_ser_dept(r) for r in recs], 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/departments', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_dept(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('code'):
vals['code'] = body['code']
if body.get('parent_id'):
vals['parent_id'] = int(body['parent_id'])
rec = request.env['op.department'].sudo().create(vals)
return _json_response(_ser_dept(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/departments/<int:did>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_dept(self, did, **kw):
try:
rec = request.env['op.department'].sudo().browse(did)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'code'):
if k in body:
vals[k] = body[k]
if 'parent_id' in body:
vals['parent_id'] = int(body['parent_id']) if body['parent_id'] else False
if vals:
rec.write(vals)
return _json_response(_ser_dept(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/departments/<int:did>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_dept(self, did, **kw):
try:
rec = request.env['op.department'].sudo().browse(did)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,117 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
class ActivitiesController(http.Controller):
@http.route('/api/activities', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_activities(self, **kw):
try:
M = request.env['op.activity'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'student_id': r.student_id.id if r.student_id else 0,
'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '',
'type_id': r.type_id.id if r.type_id else 0,
'type_name': r.type_id.name if r.type_id else '',
'date': str(r.date) if hasattr(r, 'date') and r.date else '',
'description': getattr(r, 'description', '') or '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/activities', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_activity(self, **kw):
try:
body = _get_json_body()
vals = {}
if body.get('student_id'):
vals['student_id'] = int(body['student_id'])
if body.get('type_id'):
vals['type_id'] = int(body['type_id'])
if body.get('date'):
vals['date'] = body['date']
if body.get('description'):
vals['description'] = body['description']
rec = request.env['op.activity'].sudo().create(vals)
return _json_response({'id': rec.id})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/activities/<int:aid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_activity(self, aid, **kw):
try:
rec = request.env['op.activity'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'description' in body:
vals['description'] = body['description']
if 'date' in body:
vals['date'] = body['date']
if vals:
rec.write(vals)
return _json_response({'id': rec.id})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/activities/<int:aid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_activity(self, aid, **kw):
try:
rec = request.env['op.activity'].sudo().browse(aid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Activity Types ───────────────────────────────────────────────
@http.route('/api/activity-types', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_types(self, **kw):
try:
M = request.env['op.activity.type'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{'id': r.id, 'name': r.name or ''} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/activity-types', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_type(self, **kw):
try:
body = _get_json_body()
rec = request.env['op.activity.type'].sudo().create({'name': body.get('name', '')})
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/activity-types/<int:tid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_type(self, tid, **kw):
try:
rec = request.env['op.activity.type'].sudo().browse(tid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,314 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_register(r):
return {
'id': r.id,
'name': r.name or '',
'course_id': r.course_id.id if r.course_id else 0,
'course_name': r.course_id.name if r.course_id else '',
'start_date': str(r.start_date) if r.start_date else '',
'end_date': str(r.end_date) if r.end_date else '',
'min_count': r.min_count if hasattr(r, 'min_count') else 0,
'max_count': r.max_count if hasattr(r, 'max_count') else 0,
'application_count': len(r.admission_ids) if hasattr(r, 'admission_ids') else 0,
'state': r.state or 'draft',
}
def _ser_admission(a):
nationality = getattr(a, 'nationality', False)
nationality_name = ''
nationality_id = None
if nationality:
nationality_id = nationality.id
nationality_name = nationality.name or ''
return {
'id': a.id,
'name': a.name or '',
'application_number': getattr(a, 'application_number', '') or str(a.id),
'register_id': a.register_id.id if hasattr(a, 'register_id') and a.register_id else 0,
'register_name': a.register_id.name if hasattr(a, 'register_id') and a.register_id else '',
'course_id': a.course_id.id if a.course_id else 0,
'course_name': a.course_id.name if a.course_id else '',
'batch_id': a.batch_id.id if hasattr(a, 'batch_id') and a.batch_id else None,
'batch_name': a.batch_id.name if hasattr(a, 'batch_id') and a.batch_id else None,
'first_name': getattr(a, 'first_name', '') or a.name or '',
'middle_name': getattr(a, 'middle_name', '') or '',
'last_name': getattr(a, 'last_name', '') or '',
'email': getattr(a, 'email', '') or '',
'phone': getattr(a, 'phone', '') or '',
'birth_date': str(a.birth_date) if hasattr(a, 'birth_date') and a.birth_date else None,
'gender': a.gender if hasattr(a, 'gender') else 'o',
'nationality_id': nationality_id,
'nationality_name': nationality_name,
'state': a.state or 'draft',
'fees': getattr(a, 'fees', 0) or 0,
'student_id': a.student_id.id if hasattr(a, 'student_id') and a.student_id else None,
'created_at': str(a.create_date) if a.create_date else '',
}
class AdmissionsController(http.Controller):
# ── Admission Registers ──────────────────────────────────────────
@http.route('/api/admission-registers', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_registers(self, **kw):
try:
M = request.env['op.admission.register'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [_ser_register(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admission-registers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_register(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
if body.get('start_date'):
vals['start_date'] = body['start_date']
if body.get('end_date'):
vals['end_date'] = body['end_date']
if body.get('min_count'):
vals['min_count'] = int(body['min_count'])
if body.get('max_count'):
vals['max_count'] = int(body['max_count'])
rec = request.env['op.admission.register'].sudo().create(vals)
return _json_response(_ser_register(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admission-registers/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_register(self, rid, **kw):
try:
rec = request.env['op.admission.register'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'start_date', 'end_date'):
if k in body:
vals[k] = body[k]
for k in ('min_count', 'max_count', 'course_id'):
if k in body:
vals[k] = int(body[k])
if vals:
rec.write(vals)
return _json_response(_ser_register(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admission-registers/<int:rid>/confirm', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def confirm_register(self, rid, **kw):
try:
rec = request.env['op.admission.register'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
if hasattr(rec, 'action_confirm'):
rec.action_confirm()
else:
rec.write({'state': 'confirm'})
return _json_response(_ser_register(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admission-registers/<int:rid>/close', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def close_register(self, rid, **kw):
try:
rec = request.env['op.admission.register'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'done'})
return _json_response(_ser_register(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admission-registers/<int:rid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_register(self, rid, **kw):
try:
rec = request.env['op.admission.register'].sudo().browse(rid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Admissions ───────────────────────────────────────────────────
@http.route('/api/admissions', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_admissions(self, **kw):
try:
M = request.env['op.admission'].sudo()
domain = []
if kw.get('state'):
domain.append(('state', '=', kw['state']))
if kw.get('register_id'):
domain.append(('register_id', '=', int(kw['register_id'])))
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
q = (kw.get('q') or kw.get('search') or '').strip()
if q:
domain += ['|', '|', '|', '|',
('name', 'ilike', q),
('first_name', 'ilike', q),
('last_name', 'ilike', q),
('email', 'ilike', q),
('application_number', 'ilike', q)]
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_ser_admission(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admissions/<int:aid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_admission(self, aid, **kw):
try:
rec = request.env['op.admission'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_admission(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admissions', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_admission(self, **kw):
try:
from odoo import fields as odoo_fields
body = _get_json_body()
vals = {
'name': f"{body.get('first_name', '')} {body.get('last_name', '')}".strip()
or body.get('name') or 'Applicant',
'application_date': body.get('application_date') or odoo_fields.Date.today(),
}
if body.get('register_id'):
vals['register_id'] = int(body['register_id'])
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
for k in ('first_name', 'middle_name', 'last_name', 'email', 'phone', 'gender', 'birth_date'):
if body.get(k):
vals[k] = body[k]
if body.get('nationality_id') or body.get('nationality'):
vals['nationality'] = int(body.get('nationality_id') or body.get('nationality'))
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
rec = request.env['op.admission'].sudo().create(vals)
return _json_response(_ser_admission(rec))
except Exception as e:
_logger.exception('create_admission')
return _error_response(str(e), 500)
@http.route('/api/admissions/<int:aid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_admission(self, aid, **kw):
try:
rec = request.env['op.admission'].sudo().browse(aid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admissions/<int:aid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_admission(self, aid, **kw):
try:
rec = request.env['op.admission'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('first_name', 'middle_name', 'last_name', 'email', 'phone',
'gender', 'birth_date', 'name'):
if k in body:
vals[k] = body[k]
for fk in ('register_id', 'course_id', 'batch_id'):
if fk in body and body[fk]:
vals[fk] = int(body[fk])
if vals:
rec.write(vals)
return _json_response(_ser_admission(rec))
except Exception as e:
_logger.exception('update_admission')
return _error_response(str(e), 500)
@http.route('/api/admissions/<int:aid>/submit', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def submit_admission(self, aid, **kw):
try:
rec = request.env['op.admission'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
if hasattr(rec, 'submit_form'):
rec.submit_form()
else:
rec.write({'state': 'submit'})
return _json_response(_ser_admission(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admissions/<int:aid>/confirm', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def confirm_admission(self, aid, **kw):
try:
rec = request.env['op.admission'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
if hasattr(rec, 'confirm_in_progress'):
rec.confirm_in_progress()
else:
rec.write({'state': 'confirm'})
return _json_response(_ser_admission(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admissions/<int:aid>/admit', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def admit(self, aid, **kw):
try:
rec = request.env['op.admission'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
if hasattr(rec, 'enroll_student'):
rec.enroll_student()
else:
rec.write({'state': 'admission'})
return _json_response(_ser_admission(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/admissions/<int:aid>/reject', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def reject_admission(self, aid, **kw):
try:
rec = request.env['op.admission'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'reject'})
return _json_response(_ser_admission(rec))
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,52 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _ser_att(line):
sheet = line.attendance_id if hasattr(line, 'attendance_id') else None
student = line.student_id if hasattr(line, 'student_id') else None
return {
'id': line.id,
'date': str(sheet.attendance_date) if sheet and hasattr(sheet, 'attendance_date') and sheet.attendance_date else '',
'course_id': sheet.register_id.course_id.id if sheet and hasattr(sheet, 'register_id') and sheet.register_id and sheet.register_id.course_id else 0,
'course_name': sheet.register_id.course_id.name if sheet and hasattr(sheet, 'register_id') and sheet.register_id and sheet.register_id.course_id else '',
'student_id': student.id if student else 0,
'student_name': student.partner_id.name if student and student.partner_id else '',
'status': line.present and 'present' or 'absent' if hasattr(line, 'present') else 'present',
}
class AttendanceController(http.Controller):
@http.route('/api/attendance', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_attendance(self, **kw):
try:
M = request.env['op.attendance.line'].sudo()
domain = []
if kw.get('student_id'):
domain.append(('student_id', '=', int(kw['student_id'])))
if kw.get('date'):
domain.append(('attendance_id.attendance_date', '=', kw['date']))
recs = M.search(domain, limit=200, order='id desc')
data = [_ser_att(r) for r in recs]
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_attendance')
return _error_response(str(e), 500)
@http.route('/api/attendance', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def record_attendance(self, **kw):
try:
body = _get_json_body()
_logger.info('record_attendance body: %s', body)
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,96 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_classroom(r):
return {
'id': r.id,
'name': r.name or '',
'code': getattr(r, 'code', '') or '',
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
'capacity': getattr(r, 'capacity', 0) or 0,
}
class ClassroomsController(http.Controller):
@http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_groups(self, **kw):
try:
M = request.env['op.classroom'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [_ser_classroom(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_group(self, gid, **kw):
try:
rec = request.env['op.classroom'].sudo().browse(gid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_classroom(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_group(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('code'):
vals['code'] = body['code']
if body.get('capacity'):
vals['capacity'] = int(body['capacity'])
rec = request.env['op.classroom'].sudo().create(vals)
return _json_response(_ser_classroom(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_group(self, gid, **kw):
try:
rec = request.env['op.classroom'].sudo().browse(gid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def transfer(self, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>/members', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def add_members(self, gid, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>/members/remove', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_members(self, gid, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,402 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_board(b):
return {
'id': b.id,
'name': b.name or '',
'course_id': b.course_id.id if b.course_id else 0,
'course_name': b.course_id.name if b.course_id else '',
'batch_id': b.batch_id.id if b.batch_id else None,
'batch_name': b.batch_id.name if b.batch_id else None,
'chapter_id': b.chapter_id.id if b.chapter_id else None,
'chapter_name': b.chapter_id.name if b.chapter_id else None,
'is_enabled': b.is_enabled,
'allow_student_posts': b.allow_student_posts,
'post_count': b.post_count or 0,
'assignment_id': None,
}
def _ser_post(p):
return {
'id': p.id,
'board_id': p.board_id.id,
'parent_id': p.parent_id.id if p.parent_id else None,
'author_id': p.author_id.id,
'author_name': p.author_id.name or '',
'author_role': '',
'title': p.title or '',
'content': p.content or '',
'attachment_ids': [],
'attachment_names': [],
'is_pinned': p.is_pinned,
'is_resolved': p.is_resolved,
'reply_count': len(p.child_ids),
'created_at': str(p.create_date) if p.create_date else '',
}
def _ser_announcement(a):
return {
'id': a.id,
'title': a.title or '',
'content': a.content or '',
'author_id': a.author_id.id,
'author_name': a.author_id.name or '',
'course_id': a.course_id.id if a.course_id else None,
'course_name': a.course_id.name if a.course_id else None,
'batch_id': a.batch_id.id if a.batch_id else None,
'batch_name': a.batch_id.name if a.batch_id else None,
'priority': a.priority or 'normal',
'is_published': a.is_published,
'published_at': str(a.published_at) if a.published_at else None,
'expires_at': str(a.expires_at) if a.expires_at else None,
'send_email': a.send_email,
'attachment_ids': [],
'attachment_names': [],
}
def _ser_message(m):
return {
'id': m.id,
'sender_id': m.sender_id.id,
'sender_name': m.sender_id.name or '',
'recipient_id': m.recipient_id.id,
'recipient_name': m.recipient_id.name or '',
'subject': m.subject or '',
'content': m.content or '',
'is_read': m.is_read,
'read_at': str(m.read_at) if m.read_at else None,
'attachment_ids': [],
'attachment_names': [],
'send_email_copy': m.send_email_copy,
'created_at': str(m.create_date) if m.create_date else '',
}
class CommunicationController(http.Controller):
# ── Discussion Boards ────────────────────────────────────────────
@http.route('/api/discussion-boards', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_boards(self, **kw):
try:
M = request.env['encoach.discussion.board'].sudo()
domain = []
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('batch_id'):
domain.append(('batch_id', '=', int(kw['batch_id'])))
recs = M.search(domain, limit=200, order='id desc')
return _json_response([_ser_board(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_board(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
rec = request.env['encoach.discussion.board'].sudo().create(vals)
return _json_response(_ser_board(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_board(self, bid, **kw):
try:
rec = request.env['encoach.discussion.board'].sudo().browse(bid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'is_enabled' in body:
vals['is_enabled'] = bool(body['is_enabled'])
if 'allow_student_posts' in body:
vals['allow_student_posts'] = bool(body['allow_student_posts'])
if vals:
rec.write(vals)
return _json_response(_ser_board(rec))
except Exception as e:
return _error_response(str(e), 500)
# ── Posts ────────────────────────────────────────────────────────
@http.route('/api/discussion-boards/<int:bid>/posts', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_posts(self, bid, **kw):
try:
M = request.env['encoach.discussion.post'].sudo()
domain = [('board_id', '=', bid), ('parent_id', '=', False)]
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='is_pinned desc, create_date desc')
items = [_ser_post(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/<int:bid>/posts', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_post(self, bid, **kw):
try:
body = _get_json_body()
vals = {
'board_id': bid,
'author_id': request.env.uid,
'content': body.get('content', ''),
}
if body.get('title'):
vals['title'] = body['title']
if body.get('parent_id'):
vals['parent_id'] = int(body['parent_id'])
rec = request.env['encoach.discussion.post'].sudo().create(vals)
return _json_response(_ser_post(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/posts/<int:pid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_post(self, pid, **kw):
try:
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'content' in body:
vals['content'] = body['content']
if 'title' in body:
vals['title'] = body['title']
if vals:
rec.write(vals)
return _json_response(_ser_post(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/posts/<int:pid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_post(self, pid, **kw):
try:
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/posts/<int:pid>/pin', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def pin_post(self, pid, **kw):
try:
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
pinned = body.get('pinned', not rec.is_pinned)
rec.write({'is_pinned': pinned})
return _json_response(_ser_post(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/posts/<int:pid>/resolve', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def resolve_post(self, pid, **kw):
try:
rec = request.env['encoach.discussion.post'].sudo().browse(pid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'is_resolved': True})
return _json_response(_ser_post(rec))
except Exception as e:
return _error_response(str(e), 500)
# ── Announcements ────────────────────────────────────────────────
@http.route('/api/announcements', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_announcements(self, **kw):
try:
M = request.env['encoach.announcement'].sudo()
domain = []
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('priority'):
domain.append(('priority', '=', kw['priority']))
recs = M.search(domain, limit=200, order='create_date desc')
return _json_response([_ser_announcement(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/announcements', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_announcement(self, **kw):
try:
body = _get_json_body()
vals = {
'title': body.get('title', ''),
'content': body.get('content', ''),
'author_id': request.env.uid,
'priority': body.get('priority', 'normal'),
'send_email': body.get('send_email', False),
}
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
if body.get('expires_at'):
vals['expires_at'] = body['expires_at']
rec = request.env['encoach.announcement'].sudo().create(vals)
return _json_response(_ser_announcement(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/announcements/<int:aid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_announcement(self, aid, **kw):
try:
rec = request.env['encoach.announcement'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('title', 'content', 'priority', 'expires_at'):
if k in body:
vals[k] = body[k]
if 'send_email' in body:
vals['send_email'] = bool(body['send_email'])
if vals:
rec.write(vals)
return _json_response(_ser_announcement(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/announcements/<int:aid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_announcement(self, aid, **kw):
try:
rec = request.env['encoach.announcement'].sudo().browse(aid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/announcements/<int:aid>/publish', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def publish_announcement(self, aid, **kw):
try:
rec = request.env['encoach.announcement'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
from datetime import datetime
rec.write({'is_published': True, 'published_at': str(datetime.now())})
return _json_response(_ser_announcement(rec))
except Exception as e:
return _error_response(str(e), 500)
# ── Messages ─────────────────────────────────────────────────────
@http.route('/api/messages', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_messages(self, **kw):
try:
M = request.env['encoach.message'].sudo()
uid = request.env.uid
domain = [('recipient_id', '=', uid)]
if kw.get('is_read') == 'false':
domain.append(('is_read', '=', False))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='create_date desc')
items = [_ser_message(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/messages/sent', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_sent_messages(self, **kw):
try:
M = request.env['encoach.message'].sudo()
uid = request.env.uid
offset, limit, page = _paginate(kw)
domain = [('sender_id', '=', uid)]
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='create_date desc')
items = [_ser_message(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/messages', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def send_message(self, **kw):
try:
body = _get_json_body()
vals = {
'sender_id': request.env.uid,
'recipient_id': int(body.get('recipient_id', 0)),
'subject': body.get('subject', ''),
'content': body.get('content', ''),
'send_email_copy': body.get('send_email_copy', False),
}
rec = request.env['encoach.message'].sudo().create(vals)
return _json_response(_ser_message(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/messages/<int:mid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_message(self, mid, **kw):
try:
rec = request.env['encoach.message'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
if not rec.is_read and rec.recipient_id.id == request.env.uid:
from datetime import datetime
rec.write({'is_read': True, 'read_at': str(datetime.now())})
return _json_response(_ser_message(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/messages/<int:mid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_message(self, mid, **kw):
try:
rec = request.env['encoach.message'].sudo().browse(mid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/messages/unread-count', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def unread_count(self, **kw):
try:
count = request.env['encoach.message'].sudo().search_count([
('recipient_id', '=', request.env.uid),
('is_read', '=', False),
])
return _json_response({'count': count})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,613 @@
import logging
import base64
from odoo import http, fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_chapter(c):
objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective'])
return {
'id': c.id,
'name': c.name or '',
'course_id': c.course_id.id if c.course_id else 0,
'course_name': c.course_id.name if c.course_id else '',
'sequence': c.sequence,
'description': c.description or '',
'start_date': str(c.start_date) if c.start_date else None,
'end_date': str(c.end_date) if c.end_date else None,
'unlock_mode': c.unlock_mode or 'manual',
'is_unlocked': c.is_unlocked,
'topic_id': c.topic_id.id if c.topic_id else None,
'topic_name': c.topic_id.name if c.topic_id else None,
'domain_id': c.domain_id.id if c.domain_id else None,
'domain_name': c.domain_id.name if c.domain_id else None,
'learning_objective_ids': objectives.ids,
'learning_objective_names': objectives.mapped('name'),
'material_count': c.material_count or 0,
'assignment_ids': [],
'exam_ids': [],
}
def _ser_material(m):
return {
'id': m.id,
'name': m.name or '',
'chapter_id': m.chapter_id.id,
'type': m.type or 'pdf',
'file_url': f'/api/materials/{m.id}/download' if m.file_data else None,
'url': m.url or None,
'description': m.description or None,
'sequence': m.sequence,
'allow_download': m.allow_download,
'is_book': m.is_book,
'book_chapters': m.book_chapters or None,
}
class CoursewareController(http.Controller):
# ── Global Material Search ────────────────────────────────────────
@http.route('/api/materials/search', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def search_materials(self, **kw):
"""Search all materials across all courses. Used by Admin Resources page."""
try:
M = request.env['encoach.chapter.material'].sudo()
domain = []
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
if kw.get('type') and kw['type'] != 'all':
domain.append(('type', '=', kw['type']))
if kw.get('course_id'):
chapters = request.env['encoach.course.chapter'].sudo().search([
('course_id', '=', int(kw['course_id']))
])
domain.append(('chapter_id', 'in', chapters.ids))
total = M.search_count(domain)
limit = min(int(kw.get('limit', 50)), 200)
offset = int(kw.get('offset', 0))
recs = M.search(domain, limit=limit, offset=offset, order='id desc')
items = []
for m in recs:
d = _ser_material(m)
d['course_id'] = m.chapter_id.course_id.id if m.chapter_id.course_id else 0
d['course_name'] = m.chapter_id.course_id.name if m.chapter_id.course_id else ''
d['chapter_name'] = m.chapter_id.name if m.chapter_id else ''
d['source'] = 'course_material'
items.append(d)
return _json_response({'items': items, 'total': total})
except Exception as e:
return _error_response(str(e), 500)
# ── Chapters ─────────────────────────────────────────────────────
@http.route('/api/courses/<int:course_id>/chapters', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_chapters(self, course_id, **kw):
try:
M = request.env['encoach.course.chapter'].sudo()
recs = M.search([('course_id', '=', course_id)], order='sequence, id')
return _json_response([_ser_chapter(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/chapters', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_chapter(self, course_id, **kw):
try:
body = _get_json_body()
vals = {
'name': body.get('name', ''),
'course_id': course_id,
'sequence': body.get('sequence', 10),
'description': body.get('description', ''),
'unlock_mode': body.get('unlock_mode', 'manual'),
}
if body.get('start_date'):
vals['start_date'] = body['start_date']
if body.get('end_date'):
vals['end_date'] = body['end_date']
if body.get('topic_id'):
vals['topic_id'] = int(body['topic_id'])
if body.get('learning_objective_ids'):
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
rec = request.env['encoach.course.chapter'].sudo().create(vals)
return _json_response(_ser_chapter(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_chapter(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'description', 'unlock_mode', 'start_date', 'end_date'):
if k in body:
vals[k] = body[k]
if 'sequence' in body:
vals['sequence'] = int(body['sequence'])
if 'topic_id' in body:
vals['topic_id'] = int(body['topic_id']) if body['topic_id'] else False
if 'is_unlocked' in body:
vals['is_unlocked'] = bool(body['is_unlocked'])
if 'learning_objective_ids' in body:
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
if vals:
rec.write(vals)
return _json_response(_ser_chapter(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/unlock', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def unlock_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'is_unlocked': True})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/lock', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def lock_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'is_unlocked': False})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/chapters/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def reorder_chapters(self, course_id, **kw):
try:
body = _get_json_body()
ids = body.get('ids', [])
for seq, cid in enumerate(ids):
rec = request.env['encoach.course.chapter'].sudo().browse(int(cid))
if rec.exists():
rec.write({'sequence': seq * 10})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/progress', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_chapter_progress(self, cid, **kw):
try:
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if student:
prog = request.env['encoach.chapter.progress'].sudo().search([
('chapter_id', '=', cid), ('student_id', '=', student.id)
], limit=1)
if prog:
return _json_response({
'id': prog.id,
'student_id': student.id,
'student_name': student.partner_id.name or '',
'chapter_id': cid,
'status': prog.status,
'started_at': str(prog.started_at) if prog.started_at else None,
'completed_at': str(prog.completed_at) if prog.completed_at else None,
'materials_completed': prog.materials_completed,
'materials_total': prog.materials_total,
})
return _json_response({
'id': 0, 'student_id': 0, 'student_name': '', 'chapter_id': cid,
'status': 'not_started', 'started_at': None, 'completed_at': None,
'materials_completed': 0, 'materials_total': 0,
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/progress/complete', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def mark_chapter_complete(self, cid, **kw):
try:
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if not student:
return _error_response('Student not found for current user', 404)
chapter = request.env['encoach.course.chapter'].sudo().browse(cid)
if not chapter.exists():
return _error_response('Chapter not found', 404)
Progress = request.env['encoach.chapter.progress'].sudo()
prog = Progress.search([
('chapter_id', '=', cid),
('student_id', '=', student.id),
], limit=1)
now = fields.Datetime.now()
if prog:
prog.write({
'status': 'completed',
'completed_at': now,
'materials_completed': chapter.material_count,
'materials_total': chapter.material_count,
})
else:
prog = Progress.create({
'chapter_id': cid,
'student_id': student.id,
'status': 'completed',
'started_at': now,
'completed_at': now,
'materials_completed': chapter.material_count,
'materials_total': chapter.material_count,
})
course_id = chapter.course_id.id
self._check_course_completion(student.id, course_id)
return _json_response({
'success': True,
'chapter_id': cid,
'status': 'completed',
})
except Exception as e:
_logger.exception('mark_chapter_complete failed')
return _error_response(str(e), 500)
def _check_course_completion(self, student_id, course_id):
"""Check if all chapters are completed; if so, mark course as completed and enable post-test."""
chapters = request.env['encoach.course.chapter'].sudo().search([
('course_id', '=', course_id)
])
if not chapters:
return
completed = request.env['encoach.chapter.progress'].sudo().search_count([
('student_id', '=', student_id),
('chapter_id', 'in', chapters.ids),
('status', '=', 'completed'),
])
total = len(chapters)
progress_pct = int((completed / total * 100) if total else 0)
Completion = request.env['encoach.course.completion'].sudo()
comp = Completion.search([
('student_id', '=', student_id),
('course_id', '=', course_id),
], limit=1)
vals = {
'chapters_total': total,
'chapters_completed': completed,
'progress_percent': progress_pct,
}
if completed >= total:
vals['status'] = 'completed'
vals['completed_at'] = fields.Datetime.now()
vals['post_test_available'] = True
else:
vals['status'] = 'in_progress'
vals['post_test_available'] = False
if comp:
comp.write(vals)
else:
vals['student_id'] = student_id
vals['course_id'] = course_id
Completion.create(vals)
@http.route('/api/courses/<int:course_id>/completion', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_course_completion(self, course_id, **kw):
"""Get course completion status for the current student."""
try:
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if not student:
return _json_response({
'course_id': course_id,
'status': 'not_enrolled',
'chapters_total': 0,
'chapters_completed': 0,
'progress_percent': 0,
'post_test_available': False,
})
comp = request.env['encoach.course.completion'].sudo().search([
('student_id', '=', student.id),
('course_id', '=', course_id),
], limit=1)
if comp:
return _json_response({
'course_id': course_id,
'status': comp.status,
'chapters_total': comp.chapters_total,
'chapters_completed': comp.chapters_completed,
'progress_percent': comp.progress_percent,
'completed_at': str(comp.completed_at) if comp.completed_at else None,
'post_test_available': comp.post_test_available,
})
chapters = request.env['encoach.course.chapter'].sudo().search([
('course_id', '=', course_id)
])
completed = request.env['encoach.chapter.progress'].sudo().search_count([
('student_id', '=', student.id),
('chapter_id', 'in', chapters.ids),
('status', '=', 'completed'),
])
total = len(chapters)
return _json_response({
'course_id': course_id,
'status': 'in_progress' if completed > 0 else 'not_started',
'chapters_total': total,
'chapters_completed': completed,
'progress_percent': int((completed / total * 100) if total else 0),
'post_test_available': completed >= total and total > 0,
})
except Exception as e:
_logger.exception('get_course_completion failed')
return _error_response(str(e), 500)
# ── Materials ────────────────────────────────────────────────────
@http.route('/api/chapters/<int:cid>/materials', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_materials(self, cid, **kw):
try:
M = request.env['encoach.chapter.material'].sudo()
recs = M.search([('chapter_id', '=', cid)], order='sequence, id')
return _json_response([_ser_material(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/materials', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def upload_material(self, cid, **kw):
try:
files = request.httprequest.files
f = files.get('file')
body = {}
ct = request.httprequest.content_type or ''
if 'application/json' in ct:
body = _get_json_body()
params = {**body, **kw}
vals = {
'chapter_id': cid,
'name': params.get('name', f.filename if f else 'Material'),
'type': params.get('type', 'pdf'),
'sequence': int(params.get('sequence', 10)),
'allow_download': str(params.get('allow_download', 'true')).lower() == 'true',
}
if f:
vals['file_data'] = base64.b64encode(f.read())
vals['file_name'] = f.filename
if params.get('url'):
vals['url'] = params['url']
if params.get('description'):
vals['description'] = params['description']
rec = request.env['encoach.chapter.material'].sudo().create(vals)
return _json_response(_ser_material(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/materials/from-resource', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_material_from_resource(self, cid, **kw):
"""Create a chapter material by copying from an existing library resource."""
try:
body = _get_json_body()
resource_id = body.get('resource_id')
if not resource_id:
return _error_response('resource_id is required', 400)
res = request.env['encoach.resource'].sudo().browse(int(resource_id))
if not res.exists():
return _error_response('Resource not found', 404)
type_map = {
'pdf': 'pdf', 'document': 'document', 'video': 'video',
'link': 'link', 'interactive': 'document',
}
vals = {
'chapter_id': cid,
'name': body.get('name') or res.name,
'type': type_map.get(res.type, 'document'),
'sequence': int(body.get('sequence', 10)),
'allow_download': True,
'url': res.url or '',
'resource_id': res.id,
}
if res.file:
vals['file_data'] = res.file
vals['file_name'] = res.name
rec = request.env['encoach.chapter.material'].sudo().create(vals)
return _json_response(_ser_material(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_material(self, mid, **kw):
try:
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'type', 'url', 'description'):
if k in body:
vals[k] = body[k]
if 'sequence' in body:
vals['sequence'] = int(body['sequence'])
if 'allow_download' in body:
vals['allow_download'] = bool(body['allow_download'])
if vals:
rec.write(vals)
return _json_response(_ser_material(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_material(self, mid, **kw):
try:
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>/download', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def download_material(self, mid, **kw):
try:
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if not rec.exists() or not rec.file_data:
return _error_response('Not found', 404)
data = base64.b64decode(rec.file_data)
fname = rec.file_name or 'download'
return request.make_response(data, headers=[
('Content-Type', 'application/octet-stream'),
('Content-Disposition', f'attachment; filename="{fname}"'),
])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>/content', type='http', auth='public', methods=['GET'], csrf=False)
def material_content(self, mid, **kw):
"""Serve material file inline (for iframe/embed) instead of as download.
Accepts JWT via Authorization header OR ?token= query param (for iframe src)."""
try:
from odoo.addons.encoach_api.controllers.base import validate_token, _get_jwt_secret
import jwt as pyjwt
user = validate_token()
if not user:
token_param = kw.get('token') or request.httprequest.args.get('token')
if token_param:
secret = _get_jwt_secret()
if secret:
try:
payload = pyjwt.decode(token_param, secret, algorithms=["HS256"])
uid = payload.get("uid")
if uid:
user = request.env['res.users'].sudo().browse(int(uid))
if user.exists():
request.update_env(user=user.id)
except Exception:
pass
if not user:
return _error_response("Authentication required", 401)
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if not rec.exists() or not rec.file_data:
return _error_response('Not found', 404)
data = base64.b64decode(rec.file_data)
fname = rec.file_name or 'file'
ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else ''
mime_map = {
'pdf': 'application/pdf',
'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
'mp3': 'audio/mpeg', 'wav': 'audio/wav',
'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'gif': 'image/gif', 'svg': 'image/svg+xml', 'webp': 'image/webp',
'html': 'text/html', 'htm': 'text/html',
'txt': 'text/plain',
}
content_type = mime_map.get(ext, 'application/octet-stream')
return request.make_response(data, headers=[
('Content-Type', content_type),
('Content-Disposition', f'inline; filename="{fname}"'),
('Cache-Control', 'public, max-age=3600'),
])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>/viewed', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def mark_viewed(self, mid, **kw):
"""Mark a material as viewed and update chapter progress."""
try:
material = request.env['encoach.chapter.material'].sudo().browse(mid)
if not material.exists():
return _error_response('Not found', 404)
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if not student:
return _json_response({'success': True})
chapter = material.chapter_id
Progress = request.env['encoach.chapter.progress'].sudo()
prog = Progress.search([
('chapter_id', '=', chapter.id),
('student_id', '=', student.id),
], limit=1)
now = fields.Datetime.now()
if prog:
new_count = min(prog.materials_completed + 1, chapter.material_count)
vals = {
'materials_completed': new_count,
'materials_total': chapter.material_count,
}
if prog.status == 'not_started':
vals['status'] = 'in_progress'
vals['started_at'] = now
if new_count >= chapter.material_count:
vals['status'] = 'completed'
vals['completed_at'] = now
prog.write(vals)
else:
status = 'completed' if chapter.material_count <= 1 else 'in_progress'
prog = Progress.create({
'chapter_id': chapter.id,
'student_id': student.id,
'status': status,
'started_at': now,
'completed_at': now if status == 'completed' else False,
'materials_completed': 1,
'materials_total': chapter.material_count,
})
if prog.status == 'completed':
self._check_course_completion(student.id, chapter.course_id.id)
return _json_response({'success': True, 'materials_completed': prog.materials_completed})
except Exception as e:
_logger.exception('mark_viewed failed')
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/materials/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def reorder_materials(self, cid, **kw):
try:
body = _get_json_body()
ids = body.get('ids', [])
for seq, mid in enumerate(ids):
rec = request.env['encoach.chapter.material'].sudo().browse(int(mid))
if rec.exists():
rec.write({'sequence': seq * 10})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,161 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
class FacilitiesController(http.Controller):
@http.route('/api/facilities', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_facilities(self, **kw):
try:
M = request.env['op.facility'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'name': r.name or '',
'code': getattr(r, 'code', '') or '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/facilities', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_facility(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('code'):
vals['code'] = body['code']
rec = request.env['op.facility'].sudo().create(vals)
return _json_response({'id': rec.id, 'name': rec.name, 'code': getattr(rec, 'code', '')})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/facilities/<int:fid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_facility(self, fid, **kw):
try:
rec = request.env['op.facility'].sudo().browse(fid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'code' in body:
vals['code'] = body['code']
if vals:
rec.write(vals)
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/facilities/<int:fid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_facility(self, fid, **kw):
try:
rec = request.env['op.facility'].sudo().browse(fid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Assets ───────────────────────────────────────────────────────
# Assets are tracked via encoach.asset (lightweight custom model since
# OpenEduCat has no dedicated asset model in this build).
def _asset_model(self):
return request.env['encoach.asset'].sudo()
@http.route('/api/assets', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_assets(self, **kw):
try:
M = self._asset_model()
offset, limit, page = _paginate(kw)
domain = []
if kw.get('facility_id'):
try:
domain.append(('facility_id', '=', int(kw['facility_id'])))
except Exception:
pass
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'name': r.name or '',
'code': r.code or '',
'facility_id': r.facility_id.id if r.facility_id else 0,
'facility_name': r.facility_id.name if r.facility_id else '',
'description': r.description or '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_assets')
return _error_response(str(e), 500)
@http.route('/api/assets', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_asset(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('Name is required', 400)
vals = {'name': name}
if body.get('code'):
vals['code'] = body['code']
if body.get('facility_id'):
vals['facility_id'] = int(body['facility_id'])
if body.get('description'):
vals['description'] = body['description']
rec = self._asset_model().create(vals)
return _json_response({
'id': rec.id, 'name': rec.name, 'code': rec.code or '',
'facility_id': rec.facility_id.id if rec.facility_id else 0,
})
except Exception as e:
_logger.exception('create_asset')
return _error_response(str(e), 500)
@http.route('/api/assets/<int:aid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_asset(self, aid, **kw):
try:
rec = self._asset_model().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'code', 'description'):
if k in body:
vals[k] = body[k]
if 'facility_id' in body:
vals['facility_id'] = int(body['facility_id']) if body['facility_id'] else False
if vals:
rec.write(vals)
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/assets/<int:aid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_asset(self, aid, **kw):
try:
rec = self._asset_model().browse(aid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,174 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _ser_cat(c):
return {
'id': c.id,
'name': c.name or '',
'sequence': c.sequence,
'audience': c.audience or 'all',
'is_published': c.is_published,
'item_count': c.item_count or 0,
}
def _ser_item(i):
return {
'id': i.id,
'category_id': i.category_id.id,
'category_name': i.category_id.name or '',
'question': i.question or '',
'answer': i.answer or '',
'sequence': i.sequence,
'audience': i.audience or 'all',
'is_published': i.is_published,
'video_url': i.video_url or '',
}
class FaqController(http.Controller):
@http.route('/api/faq/categories', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_categories(self, **kw):
try:
M = request.env['encoach.faq.category'].sudo()
domain = []
if kw.get('audience'):
domain.append(('audience', 'in', [kw['audience'], 'all']))
recs = M.search(domain, order='sequence, id')
return _json_response([_ser_cat(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/faq/categories', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_category(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('sequence') is not None:
vals['sequence'] = int(body['sequence'])
if body.get('audience'):
vals['audience'] = body['audience']
rec = request.env['encoach.faq.category'].sudo().create(vals)
return _json_response(_ser_cat(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/faq/categories/<int:cid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_category(self, cid, **kw):
try:
rec = request.env['encoach.faq.category'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'audience'):
if k in body:
vals[k] = body[k]
if 'sequence' in body:
vals['sequence'] = int(body['sequence'])
if 'is_published' in body:
vals['is_published'] = bool(body['is_published'])
if vals:
rec.write(vals)
return _json_response(_ser_cat(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/faq/categories/<int:cid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_category(self, cid, **kw):
try:
rec = request.env['encoach.faq.category'].sudo().browse(cid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Items ────────────────────────────────────────────────────────
@http.route('/api/faq/items', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_items(self, **kw):
try:
M = request.env['encoach.faq.item'].sudo()
domain = []
if kw.get('category_id'):
domain.append(('category_id', '=', int(kw['category_id'])))
if kw.get('audience'):
domain.append(('audience', 'in', [kw['audience'], 'all']))
if kw.get('search'):
domain.append(('question', 'ilike', kw['search']))
recs = M.search(domain, order='sequence, id')
return _json_response([_ser_item(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/faq/items', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_item(self, **kw):
try:
body = _get_json_body()
vals = {
'question': body.get('question', ''),
'answer': body.get('answer', ''),
'category_id': int(body.get('category_id', 0)),
}
if body.get('sequence') is not None:
vals['sequence'] = int(body['sequence'])
if body.get('audience'):
vals['audience'] = body['audience']
if body.get('video_url') is not None:
vals['video_url'] = body['video_url'] or False
rec = request.env['encoach.faq.item'].sudo().create(vals)
return _json_response(_ser_item(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/faq/items/<int:iid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_item(self, iid, **kw):
try:
rec = request.env['encoach.faq.item'].sudo().browse(iid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('question', 'answer', 'audience'):
if k in body:
vals[k] = body[k]
if 'category_id' in body:
vals['category_id'] = int(body['category_id'])
if 'sequence' in body:
vals['sequence'] = int(body['sequence'])
if 'is_published' in body:
vals['is_published'] = bool(body['is_published'])
if 'video_url' in body:
vals['video_url'] = body['video_url'] or False
if vals:
rec.write(vals)
return _json_response(_ser_item(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/faq/items/<int:iid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_item(self, iid, **kw):
try:
rec = request.env['encoach.faq.item'].sudo().browse(iid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,260 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _paginate, _get_json_body,
)
_get_json = _get_json_body
_logger = logging.getLogger(__name__)
def _serialize_plan(r):
"""Build a fees-plan record with REAL paid/remaining pulled from the
linked account.move (invoice), not a hard-coded zero."""
total = float(getattr(r, 'amount', 0) or 0)
after_discount = float(getattr(r, 'after_discount_amount', 0) or total)
invoice = r.invoice_id if hasattr(r, 'invoice_id') else False
invoice_state = ''
payment_state = 'not_paid'
amount_residual = after_discount
paid_amount = 0.0
invoice_id = 0
invoice_number = ''
if invoice and invoice.exists():
invoice_id = invoice.id
invoice_state = invoice.state or ''
payment_state = getattr(invoice, 'payment_state', '') or 'not_paid'
amount_residual = float(getattr(invoice, 'amount_residual', after_discount) or 0)
amount_total = float(getattr(invoice, 'amount_total', after_discount) or 0)
paid_amount = max(0.0, amount_total - amount_residual)
invoice_number = getattr(invoice, 'name', '') or ''
return {
'id': r.id,
'student_id': r.student_id.id if getattr(r, 'student_id', False) else 0,
'student_name': (
r.student_id.partner_id.name
if getattr(r, 'student_id', False) and r.student_id.partner_id else ''
),
'course_id': r.course_id.id if getattr(r, 'course_id', False) else 0,
'course_name': r.course_id.name if getattr(r, 'course_id', False) else '',
'batch_id': r.batch_id.id if getattr(r, 'batch_id', False) else 0,
'batch_name': r.batch_id.name if getattr(r, 'batch_id', False) else '',
'product_id': r.product_id.id if getattr(r, 'product_id', False) else 0,
'product_name': r.product_id.name if getattr(r, 'product_id', False) else '',
'date': str(r.date) if getattr(r, 'date', False) else '',
'discount': float(getattr(r, 'discount', 0) or 0),
'total_amount': total,
'after_discount_amount': after_discount,
'paid_amount': round(paid_amount, 2),
'remaining_amount': round(amount_residual, 2),
'state': getattr(r, 'state', 'draft') or 'draft',
'invoice_id': invoice_id,
'invoice_number': invoice_number,
'invoice_state': invoice_state,
'payment_state': payment_state,
'currency': r.currency_id.name if getattr(r, 'currency_id', False) else '',
}
class FeesController(http.Controller):
@http.route('/api/fees-plans', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_fees_plans(self, **kw):
try:
M = request.env['op.student.fees.details'].sudo()
offset, limit, page = _paginate(kw)
domain = []
state = (kw.get('state') or '').strip()
if state:
domain.append(('state', '=', state))
payment_state = (kw.get('payment_state') or '').strip()
q = (kw.get('q') or '').strip()
if q:
domain += ['|',
('student_id.partner_id.name', 'ilike', q),
('product_id.name', 'ilike', q)]
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_serialize_plan(r) for r in recs]
if payment_state:
items = [i for i in items if i['payment_state'] == payment_state]
return _json_response({
'items': items, 'data': items,
'total': total, 'page': page, 'size': limit,
})
except Exception as e:
_logger.exception('list_fees_plans')
return _error_response(str(e), 500)
@http.route('/api/fees-plans/<int:fid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_fees_plan(self, fid, **kw):
try:
rec = request.env['op.student.fees.details'].sudo().browse(fid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_serialize_plan(rec))
except Exception as e:
_logger.exception('get_fees_plan')
return _error_response(str(e), 500)
@http.route('/api/fees-plans/<int:fid>/create-invoice', type='http',
auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_invoice(self, fid, **kw):
"""Generate the accounting invoice for this fee line (uses the built-in
OpenEduCat `get_invoice()` model method)."""
try:
rec = request.env['op.student.fees.details'].sudo().browse(fid)
if not rec.exists():
return _error_response('Fees plan not found', 404)
if not rec.product_id:
return _error_response('This fees plan has no product; cannot invoice.', 400)
if float(rec.amount or 0) <= 0:
return _error_response('Fees amount must be positive to invoice.', 400)
# Auto-wire a default income account on the product/category if
# the Odoo chart of accounts is set up but the product was seeded
# without one. Otherwise OpenEduCat raises a UserError.
prod_tmpl = rec.product_id.sudo().product_tmpl_id
try:
fp = prod_tmpl.get_product_accounts() if hasattr(
prod_tmpl, 'get_product_accounts') else {}
except Exception:
fp = {}
if not (fp or {}).get('income'):
AA = request.env['account.account'].sudo()
# Odoo 19: account.account has a M2M `company_ids`.
try:
income_acc = AA.search([
('account_type', '=', 'income'),
('company_ids', 'in', request.env.company.id),
], limit=1)
except Exception:
income_acc = AA.browse()
if not income_acc:
income_acc = AA.search([('account_type', '=', 'income')], limit=1)
if income_acc:
try:
prod_tmpl.with_company(request.env.company).write({
'property_account_income_id': income_acc.id,
})
except Exception:
_logger.exception('auto-wire income account')
rec.get_invoice()
rec.invalidate_recordset()
return _json_response(_serialize_plan(rec))
except Exception as e:
_logger.exception('create_invoice')
return _error_response(str(e), 500)
@http.route('/api/fees-plans/<int:fid>/register-payment', type='http',
auth='public', methods=['POST'], csrf=False)
@jwt_required
def register_payment(self, fid, **kw):
"""Register a payment against the invoice linked to this fees plan.
Body: { "amount": float, "journal_id": int (optional),
"payment_date": "YYYY-MM-DD" (optional), "memo": str (optional) }"""
try:
body = _get_json() or {}
rec = request.env['op.student.fees.details'].sudo().browse(fid)
if not rec.exists():
return _error_response('Fees plan not found', 404)
invoice = rec.invoice_id
if not invoice or not invoice.exists():
return _error_response('Create an invoice before registering a payment.', 400)
if invoice.state == 'draft':
invoice.sudo().action_post()
amount = float(body.get('amount') or invoice.amount_residual or 0)
if amount <= 0:
return _error_response('Payment amount must be positive.', 400)
payment_date = body.get('payment_date') or False
memo = body.get('memo') or f"Payment for {rec.product_id.name or 'fees'}"
journal_id = body.get('journal_id')
if not journal_id:
journal = request.env['account.journal'].sudo().search(
[('type', 'in', ('bank', 'cash'))], limit=1
)
if not journal:
return _error_response(
'No bank/cash journal configured. Open Accounting → Configuration → Journals.',
400)
journal_id = journal.id
wiz_vals = {
'amount': amount,
'journal_id': int(journal_id),
'communication': memo,
}
if payment_date:
wiz_vals['payment_date'] = payment_date
wizard = request.env['account.payment.register'].sudo().with_context(
active_model='account.move', active_ids=[invoice.id]
).create(wiz_vals)
wizard.action_create_payments()
invoice.invalidate_recordset()
rec.invalidate_recordset()
return _json_response(_serialize_plan(rec))
except Exception as e:
_logger.exception('register_payment')
return _error_response(str(e), 500)
@http.route('/api/student-fees', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_student_fees(self, **kw):
try:
M = request.env['op.student.fees.details'].sudo()
offset, limit, page = _paginate(kw)
domain = []
student_id = kw.get('student_id')
if student_id:
try:
domain.append(('student_id', '=', int(student_id)))
except Exception:
pass
q = (kw.get('q') or '').strip()
if q:
domain += ['|',
('student_id.partner_id.name', 'ilike', q),
('product_id.name', 'ilike', q)]
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_serialize_plan(r) for r in recs]
return _json_response({
'items': items, 'data': items,
'total': total, 'page': page, 'size': limit,
})
except Exception as e:
_logger.exception('list_student_fees')
return _error_response(str(e), 500)
@http.route('/api/fees-terms', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_fees_terms(self, **kw):
try:
M = request.env['op.fees.terms'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'name': r.name or '',
'no_days': getattr(r, 'no_days', 0) or 0,
'line_ids': r.line_ids.ids if hasattr(r, 'line_ids') else [],
} for r in recs]
return _json_response({
'items': items, 'data': items,
'total': total, 'page': page, 'size': limit,
})
except Exception as e:
_logger.exception('list_fees_terms')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,201 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
class GradesController(http.Controller):
# ── Grades (simple list) ─────────────────────────────────────────
@http.route('/api/grades', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_grades(self, **kw):
try:
M = request.env['encoach.gradebook.line'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
data = []
for r in recs:
gb = r.gradebook_id
data.append({
'id': r.id,
'course_id': gb.course_id.id if gb.course_id else 0,
'course_name': gb.course_id.name if gb.course_id else '',
'student_id': gb.student_id.id if gb.student_id else 0,
'student_name': gb.student_id.partner_id.name if gb.student_id and gb.student_id.partner_id else '',
'assignment_title': r.assignment_name or '',
'grade': r.marks,
'max_grade': 100,
'date': str(r.create_date.date()) if r.create_date else '',
'type': r.state or 'graded',
})
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_grades')
return _error_response(str(e), 500)
# ── Gradebooks ───────────────────────────────────────────────────
@http.route('/api/gradebooks', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_gradebooks(self, **kw):
try:
M = request.env['encoach.gradebook'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'student_id': r.student_id.id if r.student_id else 0,
'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '',
'course_id': r.course_id.id if r.course_id else 0,
'course_name': r.course_id.name if r.course_id else '',
'academic_year_id': r.academic_year_id.id if r.academic_year_id else 0,
'academic_year_name': r.academic_year_id.name if r.academic_year_id else '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
# ── Gradebook Lines ──────────────────────────────────────────────
@http.route('/api/gradebook-lines', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_gradebook_lines(self, **kw):
try:
M = request.env['encoach.gradebook.line'].sudo()
offset, limit, page = _paginate(kw)
domain = []
gb_id = kw.get('gradebook_id')
if gb_id:
try:
domain.append(('gradebook_id', '=', int(gb_id)))
except Exception:
pass
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'gradebook_id': r.gradebook_id.id,
'student_name': r.gradebook_id.student_id.partner_id.name if r.gradebook_id.student_id and r.gradebook_id.student_id.partner_id else '',
'assignment_name': r.assignment_name or '',
'marks': r.marks,
'percentage': r.percentage,
'state': r.state or 'draft',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
# ── Grading Assignments (using openeducat grading.assignment) ────
@http.route('/api/grading-assignments', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_grading_assignments(self, **kw):
try:
M = request.env['grading.assignment'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'sequence': getattr(r, 'sequence', '') or '',
'name': r.name or '',
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
'subject_id': r.subject_id.id if hasattr(r, 'subject_id') and r.subject_id else 0,
'subject_name': r.subject_id.name if hasattr(r, 'subject_id') and r.subject_id else '',
'state': getattr(r, 'state', 'draft') or 'draft',
'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/grading-assignments', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_grading_assignment(self, **kw):
try:
from odoo import fields as odoo_fields
body = _get_json_body()
# assignment_type is required on grading.assignment. Find or create a default.
AType = request.env['grading.assignment.type'].sudo()
atype_id = body.get('assignment_type') or body.get('assignment_type_id')
if atype_id:
atype_id = int(atype_id)
else:
atype = AType.search([('code', '=', 'DEFAULT')], limit=1)
if not atype:
atype = AType.create({'name': 'Default', 'code': 'DEFAULT'})
atype_id = atype.id
# faculty_id is required. Prefer body, otherwise first available.
Faculty = request.env['op.faculty'].sudo()
faculty_id = body.get('faculty_id')
if faculty_id:
faculty_id = int(faculty_id)
else:
fac = Faculty.search([], limit=1)
if fac:
faculty_id = fac.id
if not faculty_id:
return _error_response('A faculty is required but none exist in the system.', 400)
vals = {
'name': body.get('name') or 'Assignment',
'issued_date': body.get('issued_date') or odoo_fields.Datetime.now(),
'assignment_type': atype_id,
'faculty_id': faculty_id,
}
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
if body.get('subject_id'):
vals['subject_id'] = int(body['subject_id'])
if body.get('point') is not None:
vals['point'] = float(body['point'])
rec = request.env['grading.assignment'].sudo().create(vals)
return _json_response({'data': {'id': rec.id, 'name': rec.name}})
except Exception as e:
_logger.exception('create_grading_assignment')
return _error_response(str(e), 500)
@http.route('/api/grading-assignments/<int:gid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_grading_assignment(self, gid, **kw):
try:
rec = request.env['grading.assignment'].sudo().browse(gid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'issued_date'):
if k in body:
vals[k] = body[k]
if 'course_id' in body:
vals['course_id'] = int(body['course_id'])
if 'subject_id' in body:
vals['subject_id'] = int(body['subject_id'])
if vals:
rec.write(vals)
return _json_response({'data': {'id': rec.id, 'name': rec.name}})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/grading-assignments/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_grading_assignment(self, gid, **kw):
try:
rec = request.env['grading.assignment'].sudo().browse(gid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,753 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_exam(e):
return {
'id': e.id,
'name': e.name or '',
'session_id': e.session_id.id if hasattr(e, 'session_id') and e.session_id else 0,
'subject_id': e.subject_id.id if hasattr(e, 'subject_id') and e.subject_id else 0,
'subject_name': e.subject_id.name if hasattr(e, 'subject_id') and e.subject_id else '',
'exam_code': getattr(e, 'exam_code', '') or '',
'start_time': str(e.start_time) if hasattr(e, 'start_time') and e.start_time else '',
'end_time': str(e.end_time) if hasattr(e, 'end_time') and e.end_time else '',
'total_marks': getattr(e, 'total_marks', 0) or 0,
'min_marks': getattr(e, 'min_marks', 0) or 0,
'state': getattr(e, 'state', 'draft') or 'draft',
'responsible_names': [p.name for p in (getattr(e, 'responsible_ids', False) or [])],
'attendee_count': len(getattr(e, 'attendees_line', False) or []),
}
def _ser_session(r, *, with_exams=False):
exams = []
if with_exams and hasattr(r, 'exam_ids'):
exams = [_ser_exam(e) for e in r.exam_ids]
exam_count = len(getattr(r, 'exam_ids', False) or [])
exam_type = getattr(r, 'exam_type', False)
return {
'id': r.id,
'name': r.name or '',
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
'batch_id': r.batch_id.id if hasattr(r, 'batch_id') and r.batch_id else 0,
'batch_name': r.batch_id.name if hasattr(r, 'batch_id') and r.batch_id else '',
'exam_code': getattr(r, 'exam_code', '') or '',
'start_date': str(r.start_date) if hasattr(r, 'start_date') and r.start_date else '',
'end_date': str(r.end_date) if hasattr(r, 'end_date') and r.end_date else '',
'exam_type_id': exam_type.id if exam_type else 0,
'exam_type_name': exam_type.name if exam_type else '',
'evaluation_type': getattr(r, 'evaluation_type', 'normal') or 'normal',
'venue': getattr(r, 'venue', '') or '',
'state': getattr(r, 'state', 'draft') or 'draft',
'exam_count': exam_count,
'exams': exams,
}
def _ser_template(r):
exam_session = getattr(r, 'exam_session_id', False)
grade_ids = []
if hasattr(r, 'grade_ids'):
grade_ids = r.grade_ids.ids
return {
'id': r.id,
'name': r.name or '',
'exam_session_id': exam_session.id if exam_session else 0,
'exam_session_name': exam_session.name if exam_session else '',
'evaluation_type': getattr(r, 'evaluation_type', 'normal') or 'normal',
'result_date': str(r.result_date) if hasattr(r, 'result_date') and r.result_date else '',
'grade_ids': grade_ids,
'state': getattr(r, 'state', 'draft') or 'draft',
}
def _ser_marksheet_line(l):
return {
'id': l.id,
'student_id': l.student_id.id if hasattr(l, 'student_id') and l.student_id else 0,
'student_name': l.student_id.name if hasattr(l, 'student_id') and l.student_id else '',
'total_marks': getattr(l, 'total_marks', 0) or 0,
'percentage': getattr(l, 'percentage', 0) or 0,
'grade': getattr(l, 'grade', '') or '',
'status': getattr(l, 'status', 'fail') or 'fail',
'results': [],
}
def _ser_marksheet(r, *, with_lines=False):
exam_session = getattr(r, 'exam_session_id', False)
line_recs = getattr(r, 'marksheet_line', False) or []
lines = [_ser_marksheet_line(l) for l in line_recs] if with_lines else []
return {
'id': r.id,
'name': r.name or '',
'exam_session_id': exam_session.id if exam_session else 0,
'exam_session_name': exam_session.name if exam_session else '',
'result_template_id': r.result_template_id.id if hasattr(r, 'result_template_id') and r.result_template_id else 0,
'generated_date': str(r.generated_date) if hasattr(r, 'generated_date') and r.generated_date else '',
'generated_by_name': r.generated_by.name if hasattr(r, 'generated_by') and r.generated_by else '',
'state': getattr(r, 'state', 'draft') or 'draft',
'total_pass': getattr(r, 'total_pass', 0) or 0,
'total_failed': getattr(r, 'total_failed', 0) or 0,
'lines': lines,
}
class InstitutionalExamsController(http.Controller):
# ── Exam Sessions (op.exam.session) ──────────────────────────────
@http.route('/api/inst-exam-sessions', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_sessions(self, **kw):
try:
M = request.env['op.exam.session'].sudo()
domain = []
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('batch_id'):
domain.append(('batch_id', '=', int(kw['batch_id'])))
if kw.get('state'):
domain.append(('state', '=', kw['state']))
q = (kw.get('q') or '').strip()
if q:
domain.append(('name', 'ilike', q))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_ser_session(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_sessions')
return _error_response(str(e), 500)
@http.route('/api/inst-exam-sessions/<int:sid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_session(self, sid, **kw):
try:
r = request.env['op.exam.session'].sudo().browse(sid)
if not r.exists():
return _error_response('Not found', 404)
return _json_response(_ser_session(r, with_exams=True))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/inst-exam-sessions', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_session(self, **kw):
try:
body = _get_json_body()
Session = request.env['op.exam.session'].sudo()
vals = {'name': body.get('name', '')}
for fk in ('course_id', 'batch_id'):
if body.get(fk):
vals[fk] = int(body[fk])
if body.get('exam_type_id') and 'exam_type' in Session._fields:
vals['exam_type'] = int(body['exam_type_id'])
if body.get('evaluation_type') and 'evaluation_type' in Session._fields:
vals['evaluation_type'] = body['evaluation_type']
if body.get('venue') and 'venue' in Session._fields:
vals['venue'] = body['venue']
if body.get('exam_code') and 'exam_code' in Session._fields:
vals['exam_code'] = body['exam_code']
for dt in ('start_date', 'end_date'):
if body.get(dt):
vals[dt] = body[dt]
rec = Session.create(vals)
return _json_response(_ser_session(rec, with_exams=True))
except Exception as e:
_logger.exception('create_session')
return _error_response(str(e), 500)
@http.route('/api/inst-exam-sessions/<int:sid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_session(self, sid, **kw):
try:
rec = request.env['op.exam.session'].sudo().browse(sid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'start_date', 'end_date', 'venue', 'exam_code', 'evaluation_type'):
if k in body and k in rec._fields:
vals[k] = body[k]
for fk in ('course_id', 'batch_id'):
if fk in body and fk in rec._fields:
vals[fk] = int(body[fk]) if body[fk] else False
if 'exam_type_id' in body and 'exam_type' in rec._fields:
vals['exam_type'] = int(body['exam_type_id']) if body['exam_type_id'] else False
if vals:
rec.write(vals)
return _json_response(_ser_session(rec, with_exams=True))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/inst-exam-sessions/<int:sid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_session(self, sid, **kw):
try:
rec = request.env['op.exam.session'].sudo().browse(sid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/inst-exam-sessions/<int:sid>/schedule', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def schedule_session(self, sid, **kw):
try:
rec = request.env['op.exam.session'].sudo().browse(sid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'schedule'})
return _json_response({'id': rec.id, 'state': 'schedule'})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/inst-exam-sessions/<int:sid>/done', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def done_session(self, sid, **kw):
try:
rec = request.env['op.exam.session'].sudo().browse(sid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'done'})
return _json_response({'id': rec.id, 'state': 'done'})
except Exception as e:
return _error_response(str(e), 500)
# ── Exams (op.exam) ──────────────────────────────────────────────
@http.route('/api/inst-exams', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_exam(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
for fk in ('session_id', 'subject_id', 'course_id'):
if body.get(fk):
vals[fk] = int(body[fk])
if body.get('total_marks'):
vals['total_marks'] = float(body['total_marks'])
rec = request.env['op.exam'].sudo().create(vals)
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/inst-exams/<int:eid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_exam(self, eid, **kw):
try:
rec = request.env['op.exam'].sudo().browse(eid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'total_marks'):
if k in body:
vals[k] = body[k]
if vals:
rec.write(vals)
return _json_response({'id': rec.id})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/inst-exams/<int:eid>/attendees', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def add_attendees(self, eid, **kw):
try:
body = _get_json_body()
student_ids = body.get('student_ids', [])
Att = request.env['op.exam.attendees'].sudo()
for sid in student_ids:
Att.create({'exam_id': eid, 'student_id': int(sid)})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/inst-exams/<int:eid>/marks', type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def enter_marks(self, eid, **kw):
try:
body = _get_json_body()
marks = body.get('marks', [])
Att = request.env['op.exam.attendees'].sudo()
for m in marks:
att = Att.search([('exam_id', '=', eid), ('student_id', '=', int(m['student_id']))], limit=1)
if att:
att.write({'marks': float(m.get('score', 0))})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Exam Types ───────────────────────────────────────────────────
@http.route('/api/exam-types', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_exam_types(self, **kw):
try:
M = request.env['op.exam.type'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit)
items = [{'id': r.id, 'name': r.name or ''} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/exam-types', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_exam_type(self, **kw):
try:
body = _get_json_body()
rec = request.env['op.exam.type'].sudo().create({'name': body.get('name', '')})
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
# ── Grade Configurations ─────────────────────────────────────────
@http.route('/api/grade-config', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_grade_config(self, **kw):
try:
M = request.env['op.grade.configuration'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit)
items = [{
'id': r.id,
'name': r.name or '',
'min_per': getattr(r, 'min_per', 0),
'max_per': getattr(r, 'max_per', 0),
'result': getattr(r, 'result', '') or '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/grade-config', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_grade_config(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
for k in ('min_per', 'max_per'):
if body.get(k) is not None:
vals[k] = float(body[k])
if body.get('result'):
vals['result'] = body['result']
rec = request.env['op.grade.configuration'].sudo().create(vals)
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
# ── Result Templates ─────────────────────────────────────────────
@http.route('/api/result-templates', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_result_templates(self, **kw):
try:
M = request.env['op.result.template'].sudo()
domain = []
if kw.get('exam_session_id'):
domain.append(('exam_session_id', '=', int(kw['exam_session_id'])))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_ser_template(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_result_templates')
return _error_response(str(e), 500)
@http.route('/api/result-templates', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_result_template(self, **kw):
try:
body = _get_json_body()
Tpl = request.env['op.result.template'].sudo()
vals = {'name': body.get('name', '')}
if body.get('exam_session_id') and 'exam_session_id' in Tpl._fields:
vals['exam_session_id'] = int(body['exam_session_id'])
if body.get('evaluation_type') and 'evaluation_type' in Tpl._fields:
vals['evaluation_type'] = body['evaluation_type']
if body.get('result_date') and 'result_date' in Tpl._fields:
vals['result_date'] = body['result_date']
grade_ids = body.get('grade_ids') or []
if grade_ids and 'grade_ids' in Tpl._fields:
vals['grade_ids'] = [(6, 0, [int(g) for g in grade_ids])]
rec = Tpl.create(vals)
return _json_response(_ser_template(rec))
except Exception as e:
_logger.exception('create_result_template')
return _error_response(str(e), 500)
@http.route(['/api/result-templates/<int:tid>/generate',
'/api/result-templates/<int:tid>/generate-marksheets'],
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def generate_marksheets(self, tid, **kw):
try:
rec = request.env['op.result.template'].sudo().browse(tid)
if not rec.exists():
return _error_response('Not found', 404)
# OpenEduCat 19 names the method `generate_result`;
# older versions used `action_generate_marksheet`.
if hasattr(rec, 'generate_result'):
rec.generate_result()
elif hasattr(rec, 'action_generate_marksheet'):
rec.action_generate_marksheet()
else:
return _error_response(
'No marksheet-generation method on op.result.template '
'(expected generate_result or action_generate_marksheet).',
400,
)
return _json_response(_ser_template(rec))
except Exception as e:
_logger.exception('generate_marksheets')
return _error_response(str(e), 500)
@http.route('/api/result-templates/<int:tid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_result_template(self, tid, **kw):
try:
rec = request.env['op.result.template'].sudo().browse(tid)
if rec.exists():
# If this template already generated marksheet registers,
# unlink them first so the FK RESTRICT doesn't block us.
session = rec.exam_session_id
if session:
regs = request.env['op.marksheet.register'].sudo().search([
('result_template_id', '=', rec.id),
])
if regs:
# Unlink the lines + registers (CASCADE ok on lines).
regs.sudo().unlink()
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_result_template')
return _error_response(str(e), 500)
@http.route('/api/result-templates/<int:tid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_result_template(self, tid, **kw):
try:
rec = request.env['op.result.template'].sudo().browse(tid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'result_date', 'evaluation_type'):
if k in body and k in rec._fields:
vals[k] = body[k]
if 'grade_ids' in body and 'grade_ids' in rec._fields:
vals['grade_ids'] = [(6, 0, [int(g) for g in body['grade_ids']])]
if vals:
rec.write(vals)
return _json_response(_ser_template(rec))
except Exception as e:
return _error_response(str(e), 500)
# ── Marksheets ───────────────────────────────────────────────────
@http.route('/api/marksheets', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_marksheets(self, **kw):
try:
M = request.env['op.marksheet.register'].sudo()
domain = []
if kw.get('exam_session_id'):
domain.append(('exam_session_id', '=', int(kw['exam_session_id'])))
if kw.get('state'):
domain.append(('state', '=', kw['state']))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_ser_marksheet(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_marksheets')
return _error_response(str(e), 500)
@http.route('/api/marksheets/<int:mid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_marksheet(self, mid, **kw):
try:
rec = request.env['op.marksheet.register'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_marksheet(rec, with_lines=True))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/marksheets/<int:mid>/validate', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def validate_marksheet(self, mid, **kw):
try:
rec = request.env['op.marksheet.register'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
if hasattr(rec, 'action_validate'):
rec.action_validate()
else:
rec.write({'state': 'validated'})
return _json_response(_ser_marksheet(rec, with_lines=True))
except Exception as e:
return _error_response(str(e), 500)
# ── Course Assignments (OpenEduCat op.assignment) ─────────────────
@http.route('/api/course-assignments', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_course_assignments(self, **kw):
try:
M = request.env['op.assignment'].sudo()
domain = []
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('batch_id'):
domain.append(('batch_id', '=', int(kw['batch_id'])))
if kw.get('state'):
domain.append(('state', '=', kw['state']))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'name': r.name or '',
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
'batch_id': r.batch_id.id if hasattr(r, 'batch_id') and r.batch_id else 0,
'subject_id': r.subject_id.id if hasattr(r, 'subject_id') and r.subject_id else 0,
'state': getattr(r, 'state', 'draft') or 'draft',
'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/course-assignments', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_course_assignment(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
for fk in ('course_id', 'batch_id', 'subject_id'):
if body.get(fk):
vals[fk] = int(body[fk])
if body.get('issued_date'):
vals['issued_date'] = body['issued_date']
rec = request.env['op.assignment'].sudo().create(vals)
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/course-assignments/<int:aid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_course_assignment(self, aid, **kw):
try:
rec = request.env['op.assignment'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response({'id': rec.id, 'name': rec.name or '', 'state': getattr(rec, 'state', 'draft')})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/course-assignments/<int:aid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_course_assignment(self, aid, **kw):
try:
rec = request.env['op.assignment'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if vals:
rec.write(vals)
return _json_response({'id': rec.id})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/course-assignments/<int:aid>/publish', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def publish_assignment(self, aid, **kw):
try:
rec = request.env['op.assignment'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'publish'})
return _json_response({'id': rec.id, 'state': 'publish'})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/course-assignments/<int:aid>/finish', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def finish_assignment(self, aid, **kw):
try:
rec = request.env['op.assignment'].sudo().browse(aid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'finish'})
return _json_response({'id': rec.id, 'state': 'finish'})
except Exception as e:
return _error_response(str(e), 500)
# ── Assignment Types ─────────────────────────────────────────────
@http.route('/api/assignment-types', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_assignment_types(self, **kw):
try:
M = request.env['grading.assignment.type'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit)
items = [{'id': r.id, 'name': r.name or ''} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/assignment-types', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_assignment_type(self, **kw):
try:
body = _get_json_body()
rec = request.env['grading.assignment.type'].sudo().create({'name': body.get('name', '')})
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)
# ── Subject Registrations (op.subject.registration) ──────────────
@http.route('/api/subject-registrations', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_subject_registrations(self, **kw):
try:
M = request.env['op.subject.registration'].sudo()
domain = []
if kw.get('student_id'):
domain.append(('student_id', '=', int(kw['student_id'])))
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('state'):
domain.append(('state', '=', kw['state']))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0,
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
'state': getattr(r, 'state', 'draft') or 'draft',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/subject-registrations', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_subject_registration(self, **kw):
try:
body = _get_json_body()
vals = {}
for fk in ('student_id', 'course_id'):
if body.get(fk):
vals[fk] = int(body[fk])
rec = request.env['op.subject.registration'].sudo().create(vals)
return _json_response({'id': rec.id})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/subject-registrations/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_subject_registration(self, rid, **kw):
try:
rec = request.env['op.subject.registration'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response({'id': rec.id})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/subject-registrations/<int:rid>/confirm', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def confirm_registration(self, rid, **kw):
try:
rec = request.env['op.subject.registration'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'confirm'})
return _json_response({'id': rec.id, 'state': 'confirm'})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/subject-registrations/<int:rid>/reject', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def reject_registration(self, rid, **kw):
try:
rec = request.env['op.subject.registration'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'reject'})
return _json_response({'id': rec.id, 'state': 'reject'})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/subject-registrations/available', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def available_subjects(self, **kw):
try:
return _json_response([])
except Exception as e:
return _error_response(str(e), 500)
# ── Submissions ──────────────────────────────────────────────────
@http.route('/api/course-assignments/<int:aid>/submissions', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_submissions(self, aid, **kw):
try:
M = request.env['op.assignment.sub.line'].sudo()
domain = [('assignment_id', '=', aid)]
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0,
'state': getattr(r, 'state', 'draft') or 'draft',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/submissions/<int:sid>', type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def grade_submission(self, sid, **kw):
try:
rec = request.env['op.assignment.sub.line'].sudo().browse(sid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'marks' in body:
vals['marks'] = float(body['marks'])
if vals:
rec.write(vals)
return _json_response({'id': rec.id})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,106 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_lesson(r):
return {
'id': r.id,
'name': r.name or '',
'lesson_topic': r.lesson_topic or '',
'subject_id': r.subject_id.id if r.subject_id else 0,
'subject_name': r.subject_id.name if r.subject_id else '',
'session_id': r.session_id.id if r.session_id else 0,
'session_name': r.session_id.display_name if r.session_id else '',
'course_id': r.course_id.id if r.course_id else 0,
'batch_id': r.batch_id.id if r.batch_id else 0,
'faculty_id': r.faculty_id.id if r.faculty_id else 0,
'start_datetime': str(r.start_datetime) if r.start_datetime else '',
'end_datetime': str(r.end_datetime) if r.end_datetime else '',
'status': r.status or 'draft',
}
class LessonsController(http.Controller):
@http.route('/api/lessons', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_lessons(self, **kw):
try:
M = request.env['encoach.lesson'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='start_datetime desc, id desc')
items = [_ser_lesson(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/lessons', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_lesson(self, **kw):
try:
body = _get_json_body()
vals = {}
if body.get('name'):
vals['name'] = body['name']
if body.get('lesson_topic'):
vals['lesson_topic'] = body['lesson_topic']
for fk in ('course_id', 'batch_id', 'subject_id', 'session_id', 'faculty_id'):
if body.get(fk):
vals[fk] = int(body[fk])
for dt in ('start_datetime', 'end_datetime'):
if body.get(dt):
vals[dt] = body[dt]
rec = request.env['encoach.lesson'].sudo().create(vals)
return _json_response(_ser_lesson(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/lessons/<int:lid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_lesson(self, lid, **kw):
try:
rec = request.env['encoach.lesson'].sudo().browse(lid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_lesson(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/lessons/<int:lid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_lesson(self, lid, **kw):
try:
rec = request.env['encoach.lesson'].sudo().browse(lid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'lesson_topic', 'start_datetime', 'end_datetime', 'status'):
if k in body:
vals[k] = body[k]
for fk in ('course_id', 'batch_id', 'subject_id', 'session_id', 'faculty_id'):
if fk in body:
vals[fk] = int(body[fk]) if body[fk] else False
if vals:
rec.write(vals)
return _json_response(_ser_lesson(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/lessons/<int:lid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_lesson(self, lid, **kw):
try:
rec = request.env['encoach.lesson'].sudo().browse(lid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,215 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
class LibraryController(http.Controller):
# ── Media ────────────────────────────────────────────────────────
@http.route('/api/library/media', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_media(self, **kw):
try:
M = request.env['op.media'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'name': r.name or '',
'isbn': getattr(r, 'isbn', '') or '',
'author': ', '.join(a.name for a in r.author_ids) if hasattr(r, 'author_ids') else '',
'edition': getattr(r, 'edition', '') or '',
'media_type': r.media_type_id.name if hasattr(r, 'media_type_id') and r.media_type_id else '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_media')
return _error_response(str(e), 500)
@http.route('/api/library/media', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_media(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('Name is required', 400)
Media = request.env['op.media'].sudo()
vals = {'name': name}
if body.get('isbn'):
vals['isbn'] = body['isbn']
if body.get('edition'):
vals['edition'] = body['edition']
author_names = []
raw_author = body.get('author')
if isinstance(raw_author, str) and raw_author.strip():
author_names = [a.strip() for a in raw_author.split(',') if a.strip()]
raw_author_ids = body.get('author_ids')
author_ids = []
if isinstance(raw_author_ids, (list, tuple)):
author_ids = [int(a) for a in raw_author_ids if a]
if author_names and 'author_ids' in Media._fields:
Author = request.env['op.author'].sudo() if 'op.author' in request.env else None
if Author is not None:
for nm in author_names:
existing = Author.search([('name', '=', nm)], limit=1)
if not existing:
existing = Author.create({'name': nm})
author_ids.append(existing.id)
if author_ids and 'author_ids' in Media._fields:
vals['author_ids'] = [(6, 0, list(set(author_ids)))]
rec = Media.create(vals)
return _json_response({'data': {'id': rec.id, 'name': rec.name}})
except Exception as e:
_logger.exception('create_media')
return _error_response(str(e), 500)
@http.route('/api/library/media/<int:mid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_media(self, mid, **kw):
try:
rec = request.env['op.media'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
author_names = [a.name for a in rec.author_ids] if hasattr(rec, 'author_ids') else []
return _json_response({
'id': rec.id,
'name': rec.name or '',
'isbn': getattr(rec, 'isbn', '') or '',
'edition': getattr(rec, 'edition', '') or '',
'author_ids': rec.author_ids.ids if hasattr(rec, 'author_ids') else [],
'author_names': author_names,
'author': ', '.join(author_names),
'publisher_ids': rec.publisher_ids.ids if hasattr(rec, 'publisher_ids') else [],
'media_type': rec.media_type_id.name if hasattr(rec, 'media_type_id') and rec.media_type_id else '',
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/library/media/<int:mid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_media(self, mid, **kw):
try:
rec = request.env['op.media'].sudo().browse(mid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Movements ────────────────────────────────────────────────────
@http.route('/api/library/movements', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_movements(self, **kw):
try:
M = request.env['op.media.movement'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'media_id': r.media_id.id if r.media_id else 0,
'media_name': r.media_id.name if r.media_id else '',
'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0,
'student_name': r.student_id.partner_id.name if hasattr(r, 'student_id') and r.student_id and r.student_id.partner_id else '',
'faculty_id': r.faculty_id.id if hasattr(r, 'faculty_id') and r.faculty_id else 0,
'faculty_name': r.faculty_id.partner_id.name if hasattr(r, 'faculty_id') and r.faculty_id and r.faculty_id.partner_id else '',
'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '',
'return_date': str(r.return_date) if hasattr(r, 'return_date') and r.return_date else '',
'state': getattr(r, 'state', 'issue') or 'issue',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/library/movements', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_movement(self, **kw):
try:
body = _get_json_body()
vals = {}
if body.get('media_id'):
vals['media_id'] = int(body['media_id'])
if body.get('student_id'):
vals['student_id'] = int(body['student_id'])
if body.get('faculty_id'):
vals['faculty_id'] = int(body['faculty_id'])
rec = request.env['op.media.movement'].sudo().create(vals)
return _json_response({'data': {'id': rec.id}})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/library/movements/<int:mid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_movement(self, mid, **kw):
try:
rec = request.env['op.media.movement'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'return_date' in body:
vals['return_date'] = body['return_date']
if vals:
rec.write(vals)
return _json_response({'data': {'id': rec.id}})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/library/movements/<int:mid>/return', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def return_movement(self, mid, **kw):
try:
rec = request.env['op.media.movement'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
from datetime import date
rec.write({'return_date': str(date.today()), 'state': 'return'})
return _json_response({'data': {'id': rec.id}})
except Exception as e:
return _error_response(str(e), 500)
# ── Library Cards ────────────────────────────────────────────────
@http.route('/api/library/cards', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_cards(self, **kw):
try:
M = request.env['op.library.card'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [{
'id': r.id,
'number': r.number if hasattr(r, 'number') else str(r.id),
'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0,
'student_name': r.student_id.partner_id.name if hasattr(r, 'student_id') and r.student_id and r.student_id.partner_id else '',
'faculty_id': r.faculty_id.id if hasattr(r, 'faculty_id') and r.faculty_id else 0,
'faculty_name': r.faculty_id.partner_id.name if hasattr(r, 'faculty_id') and r.faculty_id and r.faculty_id.partner_id else '',
} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/library/cards', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_card(self, **kw):
try:
body = _get_json_body()
vals = {}
if body.get('student_id'):
vals['student_id'] = int(body['student_id'])
rec = request.env['op.library.card'].sudo().create(vals)
return _json_response({'data': {'id': rec.id}})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,854 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _serialize_course(c):
subj = getattr(c, 'encoach_subject_id', False)
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
topics = getattr(c, 'encoach_topic_ids', c.env['encoach.topic'])
objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective'])
enrolled_count = c.env['op.student.course'].sudo().search_count([('course_id', '=', c.id)])
return {
'id': c.id,
'name': c.name or '',
'title': c.name or '',
'code': c.code or '',
'description': getattr(c, 'description', '') or '',
'status': 'active' if c.id else 'draft',
'student_count': enrolled_count,
'max_capacity': getattr(c, 'max_capacity', 0) or 0,
'subject_ids': c.subject_ids.ids if hasattr(c, 'subject_ids') else [],
'subject_names': [s.name for s in c.subject_ids] if hasattr(c, 'subject_ids') else [],
'department_id': c.department_id.id if hasattr(c, 'department_id') and c.department_id else None,
'department_name': c.department_id.name if hasattr(c, 'department_id') and c.department_id else '',
'encoach_subject_id': subj.id if subj else None,
'encoach_subject_name': subj.name if subj else '',
'topic_ids': topics.ids,
'topic_names': topics.mapped('name'),
'learning_objective_ids': objectives.ids,
'learning_objective_names': objectives.mapped('name'),
'tag_ids': tags.ids,
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
'cefr_level': getattr(c, 'cefr_level', '') or '',
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
'resource_count': getattr(c, 'resource_count', 0) or 0,
'objective_count': getattr(c, 'objective_count', 0) or 0,
}
def _serialize_student(s):
partner = s.partner_id
first = getattr(s, 'first_name', '') or (partner.name.split()[0] if partner.name else '')
last = getattr(s, 'last_name', '') or (' '.join(partner.name.split()[1:]) if partner.name else '')
course_ids = []
batch_id = None
batch_name = ''
if hasattr(s, 'course_detail_ids'):
for cd in s.course_detail_ids:
if cd.course_id:
course_ids.append(cd.course_id.id)
if cd.batch_id and not batch_id:
batch_id = cd.batch_id.id
batch_name = cd.batch_id.name or ''
return {
'id': s.id,
'name': partner.name or '',
'first_name': first,
'last_name': last,
'email': partner.email or '',
'phone': partner.phone or getattr(partner, 'mobile', '') or '',
'gender': s.gender or '',
'enrollment_date': str(s.create_date.date()) if s.create_date else None,
'status': 'active',
'course_ids': course_ids,
'batch_id': batch_id,
'batch_name': batch_name,
'partner_id': partner.id,
'user_id': s.user_id.id if s.user_id else None,
}
def _serialize_teacher(f):
partner = f.partner_id
first = partner.name.split()[0] if partner.name else ''
last = ' '.join(partner.name.split()[1:]) if partner.name else ''
dept = getattr(f, 'department_id', False)
return {
'id': f.id,
'name': partner.name or '',
'first_name': first,
'last_name': last,
'email': partner.email or '',
'phone': partner.phone or getattr(partner, 'mobile', '') or '',
'gender': f.gender or '',
'birth_date': str(f.birth_date) if hasattr(f, 'birth_date') and f.birth_date else None,
'partner_id': partner.id,
'department_id': dept.id if dept else None,
'department_name': dept.name if dept else '',
'specialization': getattr(f, 'specialization', '') or '',
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
}
def _serialize_batch(b):
SC = b.env['op.student.course'].sudo()
student_recs = SC.search([('batch_id', '=', b.id)], limit=200)
seen = set()
students = []
for sc in student_recs:
s = sc.student_id
if s and s.exists() and s.id not in seen:
seen.add(s.id)
partner = s.partner_id
students.append({
'id': s.id,
'name': partner.name if partner else '',
'email': partner.email or '' if partner else '',
})
return {
'id': b.id,
'name': b.name or '',
'code': b.code or '',
'course_id': b.course_id.id if b.course_id else 0,
'course_name': b.course_id.name if b.course_id else '',
'start_date': str(b.start_date) if b.start_date else '',
'end_date': str(b.end_date) if b.end_date else '',
'status': 'active',
'max_students': getattr(b, 'max_students', 0) or 0,
'student_count': len(students),
'students': students,
}
class LmsCoreController(http.Controller):
# ── Courses ──────────────────────────────────────────────────────
@http.route('/api/courses', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_courses(self, **kw):
try:
Course = request.env['op.course'].sudo()
domain = []
if kw.get('status'):
pass # op.course has no status field by default
offset, limit, page = _paginate(kw)
total = Course.search_count(domain)
records = Course.search(domain, offset=offset, limit=limit, order='id desc')
return _json_response({
'items': [_serialize_course(r) for r in records],
'data': [_serialize_course(r) for r in records],
'total': total,
'page': page,
'size': limit,
})
except Exception as e:
_logger.exception('list_courses failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_course(self, course_id, **kw):
try:
rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response({'data': _serialize_course(rec)})
except Exception as e:
_logger.exception('get_course failed')
return _error_response(str(e), 500)
@http.route('/api/courses', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_course(self, **kw):
try:
body = _get_json_body()
vals = {
'name': body.get('name', ''),
'code': body.get('code', ''),
}
if body.get('description'):
vals['description'] = body['description']
if body.get('max_capacity'):
vals['max_capacity'] = int(body['max_capacity'])
if body.get('encoach_subject_id'):
vals['encoach_subject_id'] = int(body['encoach_subject_id'])
if body.get('difficulty_level'):
vals['difficulty_level'] = body['difficulty_level']
if body.get('cefr_level'):
vals['cefr_level'] = body['cefr_level']
if body.get('topic_ids'):
vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])]
if body.get('learning_objective_ids'):
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
if body.get('tag_ids'):
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
rec = request.env['op.course'].sudo().create(vals)
return _json_response({'data': _serialize_course(rec)})
except Exception as e:
_logger.exception('create_course failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_course(self, course_id, **kw):
try:
rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'code', 'description'):
if k in body:
vals[k] = body[k]
if 'max_capacity' in body:
vals['max_capacity'] = int(body['max_capacity'])
if 'encoach_subject_id' in body:
vals['encoach_subject_id'] = int(body['encoach_subject_id']) if body['encoach_subject_id'] else False
if 'difficulty_level' in body:
vals['difficulty_level'] = body['difficulty_level'] or False
if 'cefr_level' in body:
vals['cefr_level'] = body['cefr_level'] or False
if 'topic_ids' in body:
vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])]
if 'learning_objective_ids' in body:
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
if 'tag_ids' in body:
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
if vals:
rec.write(vals)
return _json_response({'data': _serialize_course(rec)})
except Exception as e:
_logger.exception('update_course failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_course(self, course_id, **kw):
try:
rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists():
return _json_response({'success': True})
force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes')
enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)])
if enrollments and not force:
return _error_response(
f'Cannot delete: {len(enrollments)} student enrollment(s) reference this course. '
f'Pass force=true to cascade-delete.', 409)
if force and enrollments:
enrollments.unlink()
batches = request.env['op.batch'].sudo().search([('course_id', '=', course_id)])
if batches and force:
for b in batches:
b.course_id = False
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_course failed')
return _error_response(str(e), 500)
# ── Student: My Courses (enrollment-filtered) ──────────────────
@http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def student_my_courses(self, **kw):
"""Return only courses the current JWT user is enrolled in via op.student.course."""
try:
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if not student:
return _json_response({'items': [], 'total': 0})
course_details = request.env['op.student.course'].sudo().search([
('student_id', '=', student.id)
])
course_ids = course_details.mapped('course_id')
items = []
for c in course_ids:
data = _serialize_course(c)
cd = course_details.filtered(lambda d: d.course_id.id == c.id)
data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None
data['batch_name'] = cd[0].batch_id.name if cd and cd[0].batch_id else ''
chapters = request.env['encoach.course.chapter'].sudo().search([
('course_id', '=', c.id)
])
total_materials = sum(ch.material_count for ch in chapters)
progress_recs = request.env['encoach.chapter.progress'].sudo().search([
('student_id', '=', student.id),
('chapter_id', 'in', chapters.ids),
])
completed_chapters = len(progress_recs.filtered(lambda p: p.status == 'completed'))
data['chapter_count'] = len(chapters)
data['total_materials'] = total_materials
data['completed_chapters'] = completed_chapters
data['progress'] = int((completed_chapters / len(chapters) * 100) if chapters else 0)
items.append(data)
return _json_response({'items': items, 'total': len(items)})
except Exception as e:
_logger.exception('student_my_courses failed')
return _error_response(str(e), 500)
# ── Enroll existing student in a course ──────────────────────
@http.route('/api/students/<int:student_id>/enroll', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def enroll_student(self, student_id, **kw):
"""Enroll an existing student in one or more courses."""
try:
student = request.env['op.student'].sudo().browse(student_id)
if not student.exists():
return _error_response('Student not found', 404)
body = _get_json_body()
course_id = body.get('course_id')
course_ids = body.get('course_ids', [])
if course_id:
course_ids.append(int(course_id))
batch_id = body.get('batch_id')
enrolled = []
SC = request.env['op.student.course'].sudo()
for cid in course_ids:
cid = int(cid)
existing = SC.search([
('student_id', '=', student.id),
('course_id', '=', cid),
], limit=1)
if existing:
continue
vals = {'student_id': student.id, 'course_id': cid}
if batch_id:
vals['batch_id'] = int(batch_id)
SC.create(vals)
enrolled.append(cid)
return _json_response({
'success': True,
'enrolled_course_ids': enrolled,
'student': _serialize_student(student),
})
except Exception as e:
_logger.exception('enroll_student failed')
return _error_response(str(e), 500)
# ── Bulk enroll students in a course ─────────────────────────
@http.route('/api/courses/<int:course_id>/enroll', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def enroll_students_in_course(self, course_id, **kw):
"""Enroll multiple students (or a whole batch) into a course."""
try:
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
body = _get_json_body()
student_ids = [int(sid) for sid in body.get('student_ids', [])]
batch_id = body.get('batch_id')
if batch_id and not student_ids:
students_in_batch = request.env['op.student'].sudo().search([
('course_detail_ids.batch_id', '=', int(batch_id))
])
student_ids = students_in_batch.ids
SC = request.env['op.student.course'].sudo()
enrolled = []
for sid in student_ids:
existing = SC.search([
('student_id', '=', sid),
('course_id', '=', course_id),
], limit=1)
if existing:
continue
vals = {'student_id': sid, 'course_id': course_id}
if batch_id:
vals['batch_id'] = int(batch_id)
SC.create(vals)
enrolled.append(sid)
return _json_response({
'success': True,
'enrolled_student_ids': enrolled,
'total_enrolled': len(enrolled),
})
except Exception as e:
_logger.exception('enroll_students_in_course failed')
return _error_response(str(e), 500)
# ── Students ─────────────────────────────────────────────────────
@http.route('/api/students', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_students(self, **kw):
try:
Student = request.env['op.student'].sudo()
domain = []
if kw.get('search'):
domain = [('partner_id.name', 'ilike', kw['search'])]
if kw.get('batch_id'):
domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
offset, limit, page = _paginate(kw)
total = Student.search_count(domain)
records = Student.search(domain, offset=offset, limit=limit, order='id desc')
return _json_response({
'items': [_serialize_student(r) for r in records],
'data': [_serialize_student(r) for r in records],
'total': total,
'page': page,
'size': limit,
})
except Exception as e:
_logger.exception('list_students failed')
return _error_response(str(e), 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_student(self, student_id, **kw):
try:
rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response({'data': _serialize_student(rec)})
except Exception as e:
_logger.exception('get_student failed')
return _error_response(str(e), 500)
@http.route('/api/students', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_student(self, **kw):
try:
body = _get_json_body()
first = body.get('first_name', '')
last = body.get('last_name', '')
name = f"{first} {last}".strip()
partner_vals = {
'name': name,
'email': body.get('email', ''),
'phone': body.get('phone', ''),
}
partner = request.env['res.partner'].sudo().create(partner_vals)
student_vals = {
'partner_id': partner.id,
'gender': body.get('gender', ''),
}
if body.get('birth_date'):
student_vals['birth_date'] = body['birth_date']
student = request.env['op.student'].sudo().create(student_vals)
if body.get('course_id'):
cd_vals = {
'student_id': student.id,
'course_id': int(body['course_id']),
}
if body.get('batch_id'):
cd_vals['batch_id'] = int(body['batch_id'])
request.env['op.student.course'].sudo().create(cd_vals)
if body.get('create_portal_user', True) and body.get('email'):
user = request.env['res.users'].sudo().create({
'name': name,
'login': body['email'],
'email': body['email'],
'password': body.get('password', 'student123'),
'partner_id': partner.id,
})
student.sudo().write({'user_id': user.id})
return _json_response({'data': _serialize_student(student)})
except Exception as e:
_logger.exception('create_student failed')
return _error_response(str(e), 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_student(self, student_id, **kw):
try:
rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
partner_vals = {}
if 'first_name' in body or 'last_name' in body:
first = body.get('first_name', rec.partner_id.name.split()[0] if rec.partner_id.name else '')
last = body.get('last_name', ' '.join(rec.partner_id.name.split()[1:]) if rec.partner_id.name else '')
partner_vals['name'] = f"{first} {last}".strip()
if 'email' in body:
partner_vals['email'] = body['email']
if 'phone' in body:
partner_vals['phone'] = body['phone']
if partner_vals:
rec.partner_id.sudo().write(partner_vals)
student_vals = {}
if 'gender' in body:
student_vals['gender'] = body['gender']
if student_vals:
rec.write(student_vals)
return _json_response({'data': _serialize_student(rec)})
except Exception as e:
_logger.exception('update_student failed')
return _error_response(str(e), 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_student(self, student_id, **kw):
try:
rec = request.env['op.student'].sudo().browse(student_id)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_student failed')
return _error_response(str(e), 500)
# ── Teachers ─────────────────────────────────────────────────────
@http.route('/api/teachers', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_teachers(self, **kw):
try:
Faculty = request.env['op.faculty'].sudo()
domain = []
if kw.get('search'):
domain = [('partner_id.name', 'ilike', kw['search'])]
offset, limit, page = _paginate(kw)
total = Faculty.search_count(domain)
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
return _json_response({
'items': [_serialize_teacher(r) for r in records],
'data': [_serialize_teacher(r) for r in records],
'total': total,
'page': page,
'size': limit,
})
except Exception as e:
_logger.exception('list_teachers failed')
return _error_response(str(e), 500)
@http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_teacher(self, **kw):
try:
body = _get_json_body()
first = body.get('first_name', '')
last = body.get('last_name', '')
name = f"{first} {last}".strip()
partner = request.env['res.partner'].sudo().create({
'name': name,
'email': body.get('email', ''),
'phone': body.get('phone', ''),
})
fac_vals = {
'partner_id': partner.id,
'gender': body.get('gender', ''),
}
if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
fac_vals['department_id'] = int(body['department_id'])
if body.get('birth_date'):
fac_vals['birth_date'] = body['birth_date']
faculty = request.env['op.faculty'].sudo().create(fac_vals)
return _json_response({'data': _serialize_teacher(faculty)})
except Exception as e:
_logger.exception('create_teacher failed')
return _error_response(str(e), 500)
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_teacher(self, teacher_id, **kw):
try:
rec = request.env['op.faculty'].sudo().browse(teacher_id)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_teacher failed')
return _error_response(str(e), 500)
# ── Batches ──────────────────────────────────────────────────────
@http.route('/api/batches', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_batches(self, **kw):
try:
Batch = request.env['op.batch'].sudo()
domain = []
offset, limit, page = _paginate(kw)
total = Batch.search_count(domain)
records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
return _json_response({
'items': [_serialize_batch(r) for r in records],
'data': [_serialize_batch(r) for r in records],
'total': total,
'page': page,
'size': limit,
})
except Exception as e:
_logger.exception('list_batches failed')
return _error_response(str(e), 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_batch(self, batch_id, **kw):
try:
rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response({'data': _serialize_batch(rec)})
except Exception as e:
_logger.exception('get_batch failed')
return _error_response(str(e), 500)
@http.route('/api/batches', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_batch(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('code'):
vals['code'] = body['code']
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
if body.get('start_date'):
vals['start_date'] = body['start_date']
if body.get('end_date'):
vals['end_date'] = body['end_date']
rec = request.env['op.batch'].sudo().create(vals)
return _json_response({'data': _serialize_batch(rec)})
except Exception as e:
_logger.exception('create_batch failed')
return _error_response(str(e), 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_batch(self, batch_id, **kw):
try:
rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'code', 'start_date', 'end_date'):
if k in body:
vals[k] = body[k]
if 'course_id' in body:
vals['course_id'] = int(body['course_id'])
if vals:
rec.write(vals)
return _json_response({'data': _serialize_batch(rec)})
except Exception as e:
_logger.exception('update_batch failed')
return _error_response(str(e), 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_batch(self, batch_id, **kw):
try:
rec = request.env['op.batch'].sudo().browse(batch_id)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_batch failed')
return _error_response(str(e), 500)
@http.route('/api/batches/<int:batch_id>/students', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_batch_students(self, batch_id, **kw):
try:
SC = request.env['op.student.course'].sudo()
recs = SC.search([('batch_id', '=', batch_id)])
students = []
for sc in recs:
s = sc.student_id
if s and s.exists():
partner = s.partner_id
students.append({
'id': s.id,
'name': partner.name if partner else '',
'email': partner.email or '' if partner else '',
'course_id': sc.course_id.id if sc.course_id else 0,
'course_name': sc.course_id.name if sc.course_id else '',
})
return _json_response({'data': students, 'total': len(students)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/batches/<int:batch_id>/students', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def add_students_to_batch(self, batch_id, **kw):
"""Add students to a batch. Creates op.student.course links with batch_id."""
try:
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo()
added = []
for sid in student_ids:
existing = SC.search([
('student_id', '=', sid),
('batch_id', '=', batch_id),
], limit=1)
if existing:
continue
vals = {'student_id': sid, 'batch_id': batch_id}
if batch.course_id:
vals['course_id'] = batch.course_id.id
dup = SC.search([
('student_id', '=', sid),
('course_id', '=', batch.course_id.id),
], limit=1)
if dup:
dup.write({'batch_id': batch_id})
added.append(sid)
continue
SC.create(vals)
added.append(sid)
return _json_response({
'success': True,
'added_ids': added,
'total': len(added),
'data': _serialize_batch(batch),
})
except Exception as e:
_logger.exception('add_students_to_batch failed')
return _error_response(str(e), 500)
@http.route('/api/batches/<int:batch_id>/students/remove', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_students_from_batch(self, batch_id, **kw):
"""Remove students from a batch by clearing their batch_id."""
try:
body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo()
removed = []
for sid in student_ids:
recs = SC.search([
('student_id', '=', sid),
('batch_id', '=', batch_id),
])
if recs:
recs.write({'batch_id': False})
removed.append(sid)
batch = request.env['op.batch'].sudo().browse(batch_id)
return _json_response({
'success': True,
'removed_ids': removed,
'total': len(removed),
'data': _serialize_batch(batch) if batch.exists() else {},
})
except Exception as e:
return _error_response(str(e), 500)
# ── Taxonomy Relationship Endpoints ─────────────────────────────
@http.route('/api/courses/<int:course_id>/taxonomy', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def course_taxonomy(self, course_id, **kw):
"""Return the taxonomy tree relevant to a course: its subject, topics, and objectives."""
try:
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Not found', 404)
subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False
topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic']
objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective']
domains = topics.mapped('domain_id')
return _json_response({
'subject': {'id': subj.id, 'name': subj.name, 'code': subj.code or ''} if subj else None,
'domains': [{'id': d.id, 'name': d.name} for d in domains],
'topics': [{'id': t.id, 'name': t.name, 'domain_id': t.domain_id.id, 'domain_name': t.domain_id.name} for t in topics],
'objectives': [{'id': o.id, 'name': o.name, 'topic_id': o.topic_id.id, 'topic_name': o.topic_id.name, 'bloom_level': o.bloom_level or ''} for o in objectives],
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in (course.encoach_tag_ids if hasattr(course, 'encoach_tag_ids') else [])],
})
except Exception as e:
_logger.exception('course_taxonomy failed')
return _error_response(str(e), 500)
@http.route('/api/subjects/<int:subject_id>/courses', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def subject_courses(self, subject_id, **kw):
"""Return all courses linked to a given taxonomy subject."""
try:
courses = request.env['op.course'].sudo().search([
('encoach_subject_id', '=', subject_id)
])
return _json_response({
'items': [_serialize_course(c) for c in courses],
'total': len(courses),
})
except Exception as e:
_logger.exception('subject_courses failed')
return _error_response(str(e), 500)
@http.route('/api/topics/<int:topic_id>/resources', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def topic_resources(self, topic_id, **kw):
"""Return all resources linked to a given taxonomy topic."""
try:
resources = request.env['encoach.resource'].sudo().search([
('topic_ids', 'in', [topic_id])
])
from odoo.addons.encoach_lms_api.controllers.resources import _ser_resource
return _json_response({
'items': [_ser_resource(r) for r in resources],
'total': len(resources),
})
except Exception as e:
_logger.exception('topic_resources failed')
return _error_response(str(e), 500)
@http.route('/api/learning-objectives', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_learning_objectives(self, **kw):
"""List learning objectives, optionally filtered by topic_id or subject_id."""
try:
LO = request.env['encoach.learning.objective'].sudo()
domain = [('active', '=', True)]
if kw.get('topic_id'):
domain.append(('topic_id', '=', int(kw['topic_id'])))
elif kw.get('topic_ids'):
ids = [int(x) for x in str(kw['topic_ids']).split(',') if x.strip()]
domain.append(('topic_id', 'in', ids))
elif kw.get('subject_id'):
topics = request.env['encoach.topic'].sudo().search([
('domain_id.subject_id', '=', int(kw['subject_id']))
])
domain.append(('topic_id', 'in', topics.ids))
recs = LO.search(domain, order='sequence, name')
items = [{
'id': o.id,
'name': o.name,
'description': o.description or '',
'topic_id': o.topic_id.id,
'topic_name': o.topic_id.name,
'bloom_level': o.bloom_level or '',
'cefr_level': o.cefr_level or '',
} for o in recs]
return _json_response({'items': items, 'total': len(items)})
except Exception as e:
_logger.exception('list_learning_objectives failed')
return _error_response(str(e), 500)
@http.route('/api/domains', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_domains_lms(self, **kw):
"""List domains, optionally filtered by subject_id."""
try:
D = request.env['encoach.domain'].sudo()
domain = [('active', '=', True)]
if kw.get('subject_id'):
domain.append(('subject_id', '=', int(kw['subject_id'])))
recs = D.search(domain, order='sequence, name')
items = [{
'id': d.id,
'name': d.name,
'subject_id': d.subject_id.id,
'subject_name': d.subject_id.name,
'description': d.description or '',
} for d in recs]
return _json_response({'items': items, 'total': len(items)})
except Exception as e:
_logger.exception('list_domains_lms failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,229 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_notif(n):
return {
'id': n.id,
'title': n.title or '',
'message': n.message or '',
'type': n.type or 'info',
'is_read': n.is_read,
'read_at': str(n.read_at) if n.read_at else None,
'reference_model': n.reference_model or None,
'reference_id': n.reference_id or None,
'created_at': str(n.create_date) if n.create_date else '',
}
def _ser_rule(r):
return {
'id': r.id,
'name': r.name or '',
'event_type': r.event_type or '',
'template': r.template or '',
# Admin UI uses `active`; legacy callers still read `is_active`.
'active': bool(r.is_active),
'is_active': bool(r.is_active),
'recipients': r.recipients or 'all',
'channels': r.channels or '',
'days_before': r.days_before or 0,
'frequency': r.frequency or 'once',
'channel': r.channel or 'in_app',
'entity_id': r.entity_id.id or None,
'entity_name': r.entity_id.name if r.entity_id else None,
}
def _apply_rule_writes(body, vals):
"""Translate the incoming JSON body into ORM write values.
Accepts both the new (`active`, `channel`, `days_before`, `frequency`)
and legacy (`is_active`, `channels`) contracts.
"""
for k in ('name', 'event_type', 'template', 'recipients', 'frequency', 'channel'):
if k in body and body[k] is not None:
vals[k] = body[k]
if 'channels' in body and body['channels'] is not None:
vals['channels'] = body['channels']
if 'days_before' in body and body['days_before'] is not None:
try:
vals['days_before'] = int(body['days_before'])
except (TypeError, ValueError):
pass
if 'entity_id' in body:
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
if 'active' in body:
vals['is_active'] = bool(body['active'])
elif 'is_active' in body:
vals['is_active'] = bool(body['is_active'])
return vals
class NotificationsController(http.Controller):
@http.route('/api/notifications', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_notifications(self, **kw):
try:
M = request.env['encoach.notification'].sudo()
domain = [('user_id', '=', request.env.uid)]
if kw.get('type'):
domain.append(('type', '=', kw['type']))
if kw.get('is_read') == 'false':
domain.append(('is_read', '=', False))
elif kw.get('is_read') == 'true':
domain.append(('is_read', '=', True))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='create_date desc')
items = [_ser_notif(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/notifications/<int:nid>/read', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def mark_read(self, nid, **kw):
try:
rec = request.env['encoach.notification'].sudo().browse(nid)
if not rec.exists():
return _error_response('Not found', 404)
from datetime import datetime
rec.write({'is_read': True, 'read_at': str(datetime.now())})
return _json_response(_ser_notif(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/notifications/read-all', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def mark_all_read(self, **kw):
try:
from datetime import datetime
recs = request.env['encoach.notification'].sudo().search([
('user_id', '=', request.env.uid), ('is_read', '=', False)
])
recs.write({'is_read': True, 'read_at': str(datetime.now())})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/notifications/unread-count', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def unread_count(self, **kw):
try:
count = request.env['encoach.notification'].sudo().search_count([
('user_id', '=', request.env.uid), ('is_read', '=', False)
])
return _json_response({'count': count})
except Exception as e:
return _error_response(str(e), 500)
# ── Rules ────────────────────────────────────────────────────────
@http.route('/api/notification-rules', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_rules(self, **kw):
try:
recs = request.env['encoach.notification.rule'].sudo().search([], order='id desc')
return _json_response([_ser_rule(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/notification-rules', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_rule(self, **kw):
try:
body = _get_json_body()
if not body.get('name'):
return _error_response('name is required', 400)
if not body.get('event_type'):
return _error_response('event_type is required', 400)
vals = {
'name': body.get('name', ''),
'event_type': body.get('event_type', ''),
}
_apply_rule_writes(body, vals)
rec = request.env['encoach.notification.rule'].sudo().create(vals)
return _json_response(_ser_rule(rec))
except Exception as e:
_logger.exception('create notification rule failed')
return _error_response(str(e), 500)
@http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_rule(self, rid, **kw):
try:
rec = request.env['encoach.notification.rule'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
_apply_rule_writes(body, vals)
if vals:
rec.write(vals)
return _json_response(_ser_rule(rec))
except Exception as e:
_logger.exception('update notification rule failed')
return _error_response(str(e), 500)
@http.route('/api/notification-rules/<int:rid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_rule(self, rid, **kw):
try:
rec = request.env['encoach.notification.rule'].sudo().browse(rid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Preferences ──────────────────────────────────────────────────
@http.route('/api/notification-preferences', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_preferences(self, **kw):
try:
M = request.env['encoach.notification.preference'].sudo()
rec = M.search([('user_id', '=', request.env.uid)], limit=1)
if not rec:
rec = M.create({'user_id': request.env.uid})
return _json_response({
'email_enabled': rec.email_enabled,
'push_enabled': rec.push_enabled,
'in_app_enabled': rec.in_app_enabled,
'digest_frequency': rec.digest_frequency,
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/notification-preferences', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_preferences(self, **kw):
try:
M = request.env['encoach.notification.preference'].sudo()
rec = M.search([('user_id', '=', request.env.uid)], limit=1)
if not rec:
rec = M.create({'user_id': request.env.uid})
body = _get_json_body()
vals = {}
for k in ('email_enabled', 'push_enabled', 'in_app_enabled'):
if k in body:
vals[k] = bool(body[k])
if 'digest_frequency' in body:
vals['digest_frequency'] = body['digest_frequency']
if vals:
rec.write(vals)
return _json_response({
'email_enabled': rec.email_enabled,
'push_enabled': rec.push_enabled,
'in_app_enabled': rec.in_app_enabled,
'digest_frequency': rec.digest_frequency,
})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,151 @@
"""Payment Record endpoints — surfaces real accounting/fees data to the
`/admin/payment-record` page (previously mocked).
"""
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _ser_fee_as_payment(rec):
"""Serialize an op.student.fees.details row as a 'payment record'."""
inv = rec.invoice_id if hasattr(rec, 'invoice_id') else False
return {
'id': rec.id,
'ref': f'FEE-{rec.id:04d}',
'student_id': rec.student_id.id if rec.student_id else None,
'student_name': rec.student_id.name if rec.student_id else '',
'course_id': rec.course_id.id if rec.course_id else None,
'course_name': rec.course_id.name if rec.course_id else '',
'product_name': rec.product_id.name if rec.product_id else '',
'amount': float(rec.amount or 0.0),
'after_discount_amount': float(getattr(rec, 'after_discount_amount', rec.amount) or 0.0),
'currency': rec.currency_id.name if hasattr(rec, 'currency_id') and rec.currency_id else 'USD',
'state': rec.state or 'draft',
'paid': rec.state in ('paid', 'post'),
'date': str(rec.date) if rec.date else '',
'type': 'Student Fees',
'invoice_id': inv.id if inv else None,
'invoice_number': inv.name if inv and inv.name and inv.name != '/' else '',
'payment_state': inv.payment_state if inv and hasattr(inv, 'payment_state') else '',
}
def _ser_invoice(inv):
return {
'id': inv.id,
'ref': inv.name or f'INV-{inv.id}',
'partner_name': inv.partner_id.name if inv.partner_id else '',
'amount': float(inv.amount_total or 0.0),
'currency': inv.currency_id.name if inv.currency_id else 'USD',
'state': inv.state or 'draft',
'payment_state': getattr(inv, 'payment_state', '') or '',
'paid': getattr(inv, 'payment_state', '') == 'paid',
'date': str(inv.invoice_date) if inv.invoice_date else str(inv.date) if inv.date else '',
'type': 'Invoice',
}
class PaymentRecordsController(http.Controller):
@http.route('/api/payment-records', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_payments(self, **kw):
"""Return a unified list of payment records sourced from student fees
(and optionally accounting invoices). Supports filters: status, paid,
student_id."""
try:
FeesModel = request.env['op.student.fees.details'].sudo()
domain = []
status = kw.get('status')
if status and status != 'all':
domain.append(('state', '=', status))
if kw.get('student_id'):
try:
domain.append(('student_id', '=', int(kw['student_id'])))
except (TypeError, ValueError):
pass
recs = FeesModel.search(domain, limit=200)
items = [_ser_fee_as_payment(r) for r in recs]
paid_filter = kw.get('paid')
if paid_filter in ('true', 'false'):
want = paid_filter == 'true'
items = [i for i in items if i['paid'] == want]
return _json_response({
'data': items,
'items': items,
'total': len(items),
'totals': {
'count': len(items),
'paid': sum(1 for i in items if i['paid']),
'unpaid': sum(1 for i in items if not i['paid']),
'amount': sum(i['amount'] for i in items),
},
})
except Exception as e:
_logger.exception('list_payments')
return _error_response(str(e), 500)
@http.route('/api/payment-records/invoices', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_invoices(self, **kw):
"""Raw accounting invoices (account.move with out_invoice)."""
try:
Move = request.env['account.move'].sudo()
domain = [('move_type', '=', 'out_invoice')]
try:
size = min(int(kw.get('size') or 50), 200)
except (TypeError, ValueError):
size = 50
recs = Move.search(domain, limit=size, order='id desc')
items = [_ser_invoice(m) for m in recs]
return _json_response({
'data': items, 'items': items, 'total': len(items),
})
except Exception as e:
_logger.exception('list_invoices')
return _error_response(str(e), 500)
@http.route('/api/paymob-orders', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_paymob_orders(self, **kw):
"""List Paymob orders for the calling user.
Admins see all orders; regular users see only their own. Orders are
populated by the paymob.py controller's checkout + webhook flow.
"""
try:
Order = request.env['encoach.paymob.order'].sudo()
domain = []
if not request.env.user.has_group('base.group_system'):
domain.append(('user_id', '=', request.env.user.id))
rows = Order.search(domain, order='create_date desc', limit=200)
items = [{
'id': r.id,
'reference': r.reference,
'amount': float(r.amount_cents or 0) / 100.0,
'currency': r.currency,
'state': r.state,
'paid': r.state == 'paid',
'description': r.description or '',
'product_ref': r.product_ref or '',
'paymob_order_id': r.paymob_order_id or '',
'user_id': r.user_id.id if r.user_id else None,
'user_name': r.user_id.name if r.user_id else '',
'date': str(r.create_date) if r.create_date else '',
'last_event_at': str(r.last_event_at) if r.last_event_at else '',
'type': 'Paymob',
} for r in rows]
return _json_response({
'data': items, 'items': items, 'total': len(items),
})
except Exception as e:
_logger.exception('list_paymob_orders')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,351 @@
"""Paymob checkout initiator + HMAC-verified webhook.
This module talks to the `Paymob Accept <https://accept.paymob.com/>`_ API:
1. ``POST /api/payments/paymob/checkout``
Authenticates with the merchant API key, registers an order, and
generates a payment key. Returns the ``payment_key`` + the hosted iframe
URL the frontend should redirect the user to.
2. ``POST /api/payments/paymob/webhook``
Verifies Paymob's HMAC-SHA512 signature against a concatenated subset
of the transaction fields (per Paymob docs), marks the matching
``encoach.paymob.order`` as ``paid`` or ``failed``, and idempotently
no-ops on duplicate deliveries.
Configuration (via ``ir.config_parameter``):
* ``encoach.paymob.api_key`` — merchant API key
* ``encoach.paymob.hmac_secret`` — HMAC secret (from the Paymob dashboard)
* ``encoach.paymob.integration_id`` — default integration id (card)
* ``encoach.paymob.iframe_id`` — hosted iframe id for redirect URL
All secrets are read at request time so operators can rotate them without
restarting Odoo.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from typing import Any
import requests
from odoo import fields, http
from odoo.http import Response, request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
jwt_required,
)
_logger = logging.getLogger(__name__)
PAYMOB_BASE = "https://accept.paymob.com/api"
# Fields Paymob concatenates (in this exact order) to compute the HMAC we
# receive on webhook callbacks. Source: Paymob Accept docs, Transaction
# Processed Callback → "HMAC Calculation" section.
HMAC_FIELDS = [
"amount_cents",
"created_at",
"currency",
"error_occured",
"has_parent_transaction",
"id",
"integration_id",
"is_3d_secure",
"is_auth",
"is_capture",
"is_refunded",
"is_standalone_payment",
"is_voided",
"order.id",
"owner",
"pending",
"source_data.pan",
"source_data.sub_type",
"source_data.type",
"success",
]
def _config(key: str, default: str = "") -> str:
return (
request.env["ir.config_parameter"].sudo().get_param(f"encoach.paymob.{key}", default)
or ""
)
def _paymob_post(path: str, body: dict, timeout: int = 15) -> dict:
url = f"{PAYMOB_BASE}{path}"
resp = requests.post(url, json=body, timeout=timeout)
if resp.status_code >= 400:
raise RuntimeError(f"Paymob {path}{resp.status_code}: {resp.text[:300]}")
try:
return resp.json()
except ValueError as exc:
raise RuntimeError(f"Paymob {path} returned non-JSON: {resp.text[:300]}") from exc
def _get_nested(obj: dict, dotted: str) -> Any:
"""Walk 'a.b.c' through nested dicts, returning '' on any miss."""
cur: Any = obj
for part in dotted.split("."):
if not isinstance(cur, dict):
return ""
cur = cur.get(part, "")
return cur if cur is not None else ""
def _compute_hmac(obj: dict, secret: str) -> str:
"""Reproduce Paymob's HMAC-SHA512 over the canonical field sequence."""
parts = [str(_get_nested(obj, f)).lower() if isinstance(_get_nested(obj, f), bool)
else str(_get_nested(obj, f))
for f in HMAC_FIELDS]
message = "".join(parts).encode("utf-8")
return hmac.new(secret.encode("utf-8"), message, hashlib.sha512).hexdigest()
class EncoachPaymobController(http.Controller):
"""Checkout + webhook endpoints."""
# ------------------------------------------------------------------
# POST /api/payments/paymob/checkout
#
# Body: { amount_cents, currency, product_ref?, description?, integration_id? }
# ------------------------------------------------------------------
@http.route(
"/api/payments/paymob/checkout",
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def checkout(self, **kw):
try:
api_key = _config("api_key")
iframe_id = _config("iframe_id")
default_integration = _config("integration_id")
if not api_key or not iframe_id or not default_integration:
return _error_response(
"Paymob is not configured. "
"Set encoach.paymob.api_key / hmac_secret / integration_id / iframe_id "
"in System Parameters.",
503,
)
body = _get_json_body() or {}
try:
amount_cents = int(body.get("amount_cents") or 0)
except (TypeError, ValueError):
return _error_response("amount_cents must be an integer", 400)
if amount_cents <= 0:
return _error_response("amount_cents must be positive", 400)
currency = (body.get("currency") or "EGP").upper()
description = body.get("description") or ""
product_ref = body.get("product_ref") or ""
integration_id = str(body.get("integration_id") or default_integration)
user = request.env.user
partner = user.partner_id
# Create our own tracking row *before* calling Paymob so we never
# lose orders to partial failures.
order = request.env["encoach.paymob.order"].sudo().create({
"reference": f"enc-{user.id}-{int(fields.Datetime.now().timestamp())}",
"user_id": user.id,
"partner_id": partner.id if partner else False,
"amount_cents": amount_cents,
"currency": currency,
"description": description,
"product_ref": product_ref,
"integration_id": integration_id,
"state": "draft",
})
# Step 1 — authenticate with the merchant API key.
auth = _paymob_post("/auth/tokens", {"api_key": api_key})
auth_token = auth.get("token")
if not auth_token:
raise RuntimeError("Paymob auth returned no token")
# Step 2 — register the order with Paymob.
order_payload = {
"auth_token": auth_token,
"delivery_needed": False,
"amount_cents": amount_cents,
"currency": currency,
"items": [],
"merchant_order_id": order.reference,
}
paymob_order = _paymob_post("/ecommerce/orders", order_payload)
paymob_order_id = str(paymob_order.get("id") or "")
if not paymob_order_id:
raise RuntimeError("Paymob order registration returned no id")
# Step 3 — generate a payment key.
billing = {
"first_name": (user.name or "Guest").split(" ")[0][:50] or "Guest",
"last_name": (
" ".join((user.name or "User").split(" ")[1:])[:50] or "User"
),
"email": user.email or "no-reply@encoach.invalid",
"phone_number": partner.phone or partner.mobile or "+201000000000",
"apartment": "NA", "floor": "NA", "street": "NA",
"building": "NA", "shipping_method": "NA", "postal_code": "NA",
"city": "NA", "country": "EG", "state": "NA",
}
pk_payload = {
"auth_token": auth_token,
"amount_cents": amount_cents,
"currency": currency,
"order_id": paymob_order_id,
"billing_data": billing,
"integration_id": int(integration_id),
"lock_order_when_paid": True,
}
pk = _paymob_post("/acceptance/payment_keys", pk_payload)
payment_key = pk.get("token")
if not payment_key:
raise RuntimeError("Paymob payment_keys returned no token")
iframe_url = (
f"https://accept.paymob.com/api/acceptance/iframes/"
f"{iframe_id}?payment_token={payment_key}"
)
order.write({
"paymob_order_id": paymob_order_id,
"payment_key": payment_key,
"state": "initiated",
})
return _json_response({
"order_id": order.id,
"reference": order.reference,
"paymob_order_id": paymob_order_id,
"payment_key": payment_key,
"iframe_url": iframe_url,
})
except Exception as e:
_logger.exception("paymob checkout failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/payments/paymob/webhook
#
# Paymob delivers the full transaction JSON with an ``hmac`` query-string
# parameter. We verify the HMAC over a canonical subset of fields and
# then update the matching order.
# ------------------------------------------------------------------
@http.route(
"/api/payments/paymob/webhook",
type="http", auth="public", methods=["POST"], csrf=False,
)
def webhook(self, **kw):
try:
secret = _config("hmac_secret")
if not secret:
_logger.error("paymob webhook: hmac secret not configured")
return Response(status=503)
received_hmac = (kw.get("hmac") or "").strip()
if not received_hmac:
return Response(status=400)
raw = (request.httprequest.data or b"")
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except Exception:
return Response(status=400)
# Paymob's payload envelope is:
# { "type": "TRANSACTION", "obj": { ...fields... } }
inner = payload.get("obj") or payload
expected_hmac = _compute_hmac(inner, secret)
if not hmac.compare_digest(expected_hmac, received_hmac):
_logger.warning(
"paymob webhook: HMAC mismatch (expected=%s… got=%s…)",
expected_hmac[:16], received_hmac[:16],
)
return Response(status=401)
merchant_order_ref = (
_get_nested(inner, "order.merchant_order_id")
or inner.get("merchant_order_id")
or ""
)
paymob_order_id = str(_get_nested(inner, "order.id") or "")
success = bool(inner.get("success"))
Order = request.env["encoach.paymob.order"].sudo()
order = False
if merchant_order_ref:
order = Order.search([("reference", "=", merchant_order_ref)], limit=1)
if not order and paymob_order_id:
order = Order.search([("paymob_order_id", "=", paymob_order_id)], limit=1)
if not order:
_logger.warning(
"paymob webhook: no matching order for ref=%s paymob=%s",
merchant_order_ref, paymob_order_id,
)
# Return 200 so Paymob doesn't retry indefinitely — the event
# will still be visible in their dashboard.
return Response(status=200)
if order.state == "paid":
# Idempotent: duplicate delivery.
return Response(status=200)
payload_json = json.dumps(inner)
if success:
order.mark_paid(payload_json, received_hmac)
else:
order.mark_failed(payload_json, received_hmac)
return Response(status=200)
except Exception:
_logger.exception("paymob webhook handler crashed")
return Response(status=500)
# ------------------------------------------------------------------
# GET /api/payments/paymob/orders — list my orders
# ------------------------------------------------------------------
@http.route(
"/api/payments/paymob/orders",
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def my_orders(self, **kw):
try:
Order = request.env["encoach.paymob.order"].sudo()
domain = [("user_id", "=", request.env.user.id)]
rows = Order.search(domain, order="create_date desc", limit=200)
items = [{
"id": r.id,
"reference": r.reference,
"paymob_order_id": r.paymob_order_id or "",
"amount_cents": r.amount_cents,
"currency": r.currency,
"description": r.description or "",
"product_ref": r.product_ref or "",
"state": r.state,
"create_date": fields.Datetime.to_string(r.create_date) if r.create_date else None,
"last_event_at": (
fields.Datetime.to_string(r.last_event_at) if r.last_event_at else None
),
} for r in rows]
return _json_response({
"items": items,
"data": items,
"total": len(items),
})
except Exception as e:
_logger.exception("paymob my_orders failed")
return _error_response(str(e), 500)

View File

@@ -0,0 +1,232 @@
"""Platform settings endpoints powering `/admin/settings-platform`.
Exposes three groups:
* Codes (backed by `encoach.code`)
* Packages (simple persisted list in `ir.config_parameter`)
* Grading config (min / max / increment, in `ir.config_parameter`)
"""
import json
import logging
import uuid
from odoo import http, fields as odoo_fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
_PKG_PARAM = 'encoach.platform.packages'
_GRADE_PARAM = 'encoach.platform.grading'
_DEFAULT_PACKAGES = [
{'id': 1, 'name': 'IELTS Starter', 'price': 99, 'duration': '1 month', 'discount': 0},
{'id': 2, 'name': 'IELTS Pro', 'price': 249, 'duration': '3 months', 'discount': 15},
{'id': 3, 'name': 'Corporate Bundle','price': 1999, 'duration': '12 months','discount': 25},
]
_DEFAULT_GRADING = {'min_score': 0, 'max_score': 9, 'increment': 0.5}
def _ser_code(c):
return {
'id': c.id,
'code': c.code or '',
'code_type': c.code_type or 'individual',
'user_type': c.user_type or 'student',
'max_uses': c.max_uses or 1,
'uses': c.uses or 0,
'used': bool(c.uses and c.max_uses and c.uses >= c.max_uses),
'expiry_date': c.expiry_date.isoformat() if c.expiry_date else '',
'created': c.create_date.isoformat() if c.create_date else '',
'entity_id': c.entity_id.id if c.entity_id else None,
}
class PlatformSettingsController(http.Controller):
# ── Codes ────────────────────────────────────────────────────────────
@http.route('/api/codes', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_codes(self, **kw):
try:
Code = request.env['encoach.code'].sudo()
domain = []
ct = kw.get('code_type')
if ct and ct != 'all':
domain.append(('code_type', '=', ct))
used = kw.get('used')
try:
size = min(int(kw.get('size') or 100), 500)
except (TypeError, ValueError):
size = 100
recs = Code.search(domain, limit=size, order='id desc')
items = [_ser_code(c) for c in recs]
if used in ('true', 'false'):
want = used == 'true'
items = [i for i in items if i['used'] == want]
return _json_response({
'data': items, 'items': items, 'total': len(items),
})
except Exception as e:
_logger.exception('list_codes')
return _error_response(str(e), 500)
@http.route('/api/codes/generate', type='http', auth='public',
methods=['POST'], csrf=False)
@jwt_required
def generate_codes(self, **kw):
"""Body: { count?: int, code_type?: 'individual'|'corporate',
user_type?: 'student'|'teacher'|'corporate',
max_uses?: int, expiry_date?: ISO-str }"""
try:
body = _get_json_body()
count = max(1, min(int(body.get('count') or 1), 200))
vals_common = {
'creator_id': request.env.user.id,
'code_type': body.get('code_type') or 'individual',
'user_type': body.get('user_type') or 'student',
'max_uses': int(body.get('max_uses') or 1),
}
if body.get('expiry_date'):
vals_common['expiry_date'] = body['expiry_date']
if body.get('entity_id'):
vals_common['entity_id'] = int(body['entity_id'])
created = []
for _ in range(count):
vals = dict(vals_common, code=uuid.uuid4().hex[:8].upper())
rec = request.env['encoach.code'].sudo().create(vals)
created.append(_ser_code(rec))
return _json_response({'data': created, 'count': len(created)})
except Exception as e:
_logger.exception('generate_codes')
return _error_response(str(e), 500)
@http.route('/api/codes/<int:cid>', type='http', auth='public',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_code(self, cid, **kw):
try:
rec = request.env['encoach.code'].sudo().browse(cid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_code')
return _error_response(str(e), 500)
# ── Packages (ir.config_parameter JSON blob) ─────────────────────────
def _read_packages(self):
Param = request.env['ir.config_parameter'].sudo()
raw = Param.get_param(_PKG_PARAM)
if not raw:
return list(_DEFAULT_PACKAGES)
try:
return json.loads(raw)
except Exception:
return list(_DEFAULT_PACKAGES)
def _write_packages(self, packages):
request.env['ir.config_parameter'].sudo().set_param(
_PKG_PARAM, json.dumps(packages))
@http.route('/api/packages', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_packages(self, **kw):
pkgs = self._read_packages()
return _json_response({'data': pkgs, 'items': pkgs, 'total': len(pkgs)})
@http.route('/api/packages', type='http', auth='public',
methods=['POST'], csrf=False)
@jwt_required
def create_package(self, **kw):
try:
body = _get_json_body()
pkgs = self._read_packages()
next_id = max((p.get('id', 0) for p in pkgs), default=0) + 1
pkg = {
'id': next_id,
'name': body.get('name') or f'Package {next_id}',
'price': float(body.get('price') or 0),
'duration': body.get('duration') or '1 month',
'discount': float(body.get('discount') or 0),
}
pkgs.append(pkg)
self._write_packages(pkgs)
return _json_response(pkg)
except Exception as e:
_logger.exception('create_package')
return _error_response(str(e), 500)
@http.route('/api/packages/<int:pid>', type='http', auth='public',
methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_package(self, pid, **kw):
try:
body = _get_json_body()
pkgs = self._read_packages()
for p in pkgs:
if p.get('id') == pid:
for k in ('name', 'duration'):
if k in body:
p[k] = body[k]
for k in ('price', 'discount'):
if k in body:
p[k] = float(body[k])
self._write_packages(pkgs)
return _json_response(p)
return _error_response('Not found', 404)
except Exception as e:
_logger.exception('update_package')
return _error_response(str(e), 500)
@http.route('/api/packages/<int:pid>', type='http', auth='public',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_package(self, pid, **kw):
pkgs = self._read_packages()
new_pkgs = [p for p in pkgs if p.get('id') != pid]
self._write_packages(new_pkgs)
return _json_response({'success': True})
# ── Grading (ir.config_parameter JSON blob) ───────────────────────────
@http.route('/api/grading-config', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def get_grading(self, **kw):
Param = request.env['ir.config_parameter'].sudo()
raw = Param.get_param(_GRADE_PARAM)
try:
cfg = json.loads(raw) if raw else dict(_DEFAULT_GRADING)
except Exception:
cfg = dict(_DEFAULT_GRADING)
return _json_response({'data': cfg, **cfg})
@http.route('/api/grading-config', type='http', auth='public',
methods=['PATCH', 'PUT', 'POST'], csrf=False)
@jwt_required
def set_grading(self, **kw):
try:
body = _get_json_body()
Param = request.env['ir.config_parameter'].sudo()
raw = Param.get_param(_GRADE_PARAM)
cfg = dict(_DEFAULT_GRADING)
if raw:
try:
cfg.update(json.loads(raw))
except Exception:
pass
for k in ('min_score', 'max_score'):
if k in body:
cfg[k] = int(body[k])
if 'increment' in body:
cfg['increment'] = float(body['increment'])
if cfg['min_score'] >= cfg['max_score']:
return _error_response('min_score must be less than max_score', 400)
Param.set_param(_GRADE_PARAM, json.dumps(cfg))
return _json_response({'data': cfg, **cfg})
except Exception as e:
_logger.exception('set_grading')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,489 @@
"""Reports API.
Serves the admin "Reports" section in the sidebar:
* Student Performance -> /api/reports/student-performance
* Stats Corporate -> /api/reports/stats-corporate
* Record -> /api/reports/record
All endpoints aggregate from ``encoach.student.attempt`` (exam attempts with
per-skill band scores and CEFR level) and its surrounding records
(``res.users`` for student names, ``encoach.entity`` for corporate context,
``encoach.exam.custom`` for exam metadata).
A single attempt is "reportable" when it has a completed/scored/released
status AND at least one non-null band. Everything else is ignored so the
Reports pages never surface misleading zeros for in-progress work.
"""
import logging
from collections import defaultdict
from datetime import datetime, timedelta
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _paginate,
)
from odoo.addons.encoach_api.utils.cache import get as _cache_get, put as _cache_put
_logger = logging.getLogger(__name__)
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
from odoo.addons.encoach_ai.services.cefr_mapper import (
band_to_cefr as _cefr_code_for_band,
normalize_cefr as _normalize_cefr,
)
def _cefr_for_band(band):
"""Return the display-form CEFR label (e.g. 'B2') for an IELTS band."""
code = _cefr_code_for_band(band)
if code is None:
return None
return _normalize_cefr(code) or code
def _duration_minutes(att):
start = att.started_at
end = None
if hasattr(att, 'completed_at'):
end = att.completed_at
if not end and hasattr(att, 'finished_at'):
end = att.finished_at
if not start or not end:
return None
return int(max(0, (end - start).total_seconds() // 60))
def _attempt_completed_at(att):
if hasattr(att, 'completed_at') and att.completed_at:
return att.completed_at
if hasattr(att, 'finished_at') and att.finished_at:
return att.finished_at
return None
def _build_attempt_domain(kw, reportable=True):
"""Common filter reader used by all three endpoints."""
domain = []
if reportable:
domain.append(('status', 'in', list(REPORTABLE_STATUSES)))
entity_id = kw.get('entity_id')
if entity_id:
try:
domain.append(('entity_id', '=', int(entity_id)))
except (TypeError, ValueError):
pass
user_id = kw.get('user_id') or kw.get('student_id')
if user_id:
try:
domain.append(('student_id', '=', int(user_id)))
except (TypeError, ValueError):
pass
since = kw.get('since')
if since:
try:
domain.append(('started_at', '>=', since))
except Exception:
pass
period = (kw.get('period') or '').lower()
if period in ('day', 'week', 'month'):
delta = {'day': 1, 'week': 7, 'month': 30}[period]
cutoff = datetime.now() - timedelta(days=delta)
domain.append(('started_at', '>=', cutoff))
return domain
class ReportsController(http.Controller):
# ── 1. Student Performance ───────────────────────────────────────
@http.route('/api/reports/student-performance', type='http',
auth='public', methods=['GET'], csrf=False)
@jwt_required
def student_performance(self, **kw):
"""Return one row per student with averaged bands per skill.
Query params: ``entity_id``, ``level`` (CEFR code, case-insensitive),
``search`` (matches student name/login), ``since`` (ISO date).
"""
try:
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw)
attempts = Att.search(domain, order='started_at desc')
per_student = defaultdict(lambda: {
'attempts': [],
'reading_sum': 0.0, 'reading_n': 0,
'listening_sum': 0.0, 'listening_n': 0,
'writing_sum': 0.0, 'writing_n': 0,
'speaking_sum': 0.0, 'speaking_n': 0,
'overall_sum': 0.0, 'overall_n': 0,
'last_at': None, 'last_cefr': None,
'entity_id': None, 'entity_name': None,
})
def _accum(agg, key, value):
if value is not None and value > 0:
agg[f'{key}_sum'] += float(value)
agg[f'{key}_n'] += 1
for att in attempts:
if not att.student_id:
continue
agg = per_student[att.student_id.id]
agg['attempts'].append(att.id)
_accum(agg, 'reading', att.reading_band)
_accum(agg, 'listening', att.listening_band)
_accum(agg, 'writing', att.writing_band)
_accum(agg, 'speaking', att.speaking_band)
_accum(agg, 'overall', att.overall_band)
ct = _attempt_completed_at(att) or att.started_at
if ct and (agg['last_at'] is None or ct > agg['last_at']):
agg['last_at'] = ct
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
or _cefr_for_band(att.overall_band)
if att.entity_id and not agg['entity_id']:
agg['entity_id'] = att.entity_id.id
agg['entity_name'] = att.entity_id.name
search_lc = (kw.get('search') or '').strip().lower()
level_filter = (kw.get('level') or '').strip().upper().replace('-', '_')
rows = []
for student_id, agg in per_student.items():
user = request.env['res.users'].sudo().browse(student_id)
if not user.exists():
continue
name = user.name or user.login or f'User #{student_id}'
if search_lc and search_lc not in (name or '').lower() \
and search_lc not in (user.login or '').lower():
continue
def _avg(key):
n = agg[f'{key}_n']
if not n:
return None
return round(agg[f'{key}_sum'] / n, 2)
overall = _avg('overall')
if overall is None:
skill_avgs = [v for v in (
_avg('reading'), _avg('listening'),
_avg('writing'), _avg('speaking'),
) if v is not None]
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
if skill_avgs else None
level = agg['last_cefr'] or _cefr_for_band(overall)
if level_filter and (level or '').upper().replace('-', '_') \
!= level_filter:
continue
rows.append({
'student_id': student_id,
'student_name': name,
'login': user.login or '',
'entity_id': agg['entity_id'],
'entity_name': agg['entity_name'] or '',
'reading': _avg('reading'),
'listening': _avg('listening'),
'writing': _avg('writing'),
'speaking': _avg('speaking'),
'overall': overall,
'level': level,
'attempts_count': len(agg['attempts']),
'last_attempt_at': agg['last_at'].isoformat()
if agg['last_at'] else None,
})
rows.sort(key=lambda r: (-(r['overall'] or 0.0),
r['student_name'].lower()))
return _json_response({
'items': rows,
'total': len(rows),
})
except Exception as e:
_logger.exception('student-performance report failed')
return _error_response(str(e), 500)
# ── 2. Stats Corporate ───────────────────────────────────────────
@http.route('/api/reports/stats-corporate', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def stats_corporate(self, **kw):
"""Corporate rollups: avg by module, monthly trend, level distribution,
entity comparison.
Query params: ``entity_id``, ``threshold`` (0..100, minimum band*10 to
include), ``months`` (trend horizon; default 6).
"""
try:
# 30s TTL cache: the stats dashboard is pinged from multiple widgets
# on every page view. Invalidated implicitly once the TTL expires;
# write-heavy endpoints (exam release, attempt score commit) can
# call ``cache.invalidate("reports.stats_corporate")`` if we ever
# need stronger consistency.
cache_key = (
"reports.stats_corporate:"
f"entity={kw.get('entity_id') or ''};"
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
f"thr={kw.get('threshold') or ''};"
f"months={kw.get('months') or ''};"
f"period={kw.get('period') or ''}"
)
cached = _cache_get(cache_key)
if cached is not None:
return _json_response(cached)
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw)
try:
threshold = float(kw.get('threshold') or 0)
except (TypeError, ValueError):
threshold = 0.0
if threshold > 0:
domain.append(('overall_band', '>=', threshold / 10.0))
# --- Per-skill average via SQL aggregation ------------------
# Previously each of the four skill averages required pulling the
# full attempt rowset into Python and summing per record. This now
# runs as a single ``read_group`` per skill (avg in SQL), dropping
# the hot path from O(N) Python iterations to a constant number of
# round-trips regardless of attempt volume.
by_module = []
skill_attempts_considered = 0
for display, fname in (
('Reading', 'reading_band'),
('Listening', 'listening_band'),
('Writing', 'writing_band'),
('Speaking', 'speaking_band'),
):
agg_domain = domain + [(fname, '>', 0)]
rows = Att.read_group(
agg_domain,
fields=[f'{fname}:avg'],
groupby=[],
)
if rows:
row = rows[0]
avg = row.get(fname) or 0
n = row.get('__count') or Att.search_count(agg_domain)
else:
avg, n = 0, 0
by_module.append({
'module': display,
'score': round(avg * 10, 1) if n else 0,
'n': n,
})
skill_attempts_considered = max(skill_attempts_considered, n)
# --- Monthly trend via read_group by month ------------------
try:
months = max(1, min(24, int(kw.get('months') or 6)))
except (TypeError, ValueError):
months = 6
trend_domain = domain + [('overall_band', '!=', False)]
month_rows = Att.read_group(
trend_domain,
fields=['overall_band:avg'],
groupby=['started_at:month'],
orderby='started_at:month asc',
)
month_buckets = {}
for row in month_rows:
label = row.get('started_at:month') or row.get('started_at_month')
if not label:
continue
try:
ref = datetime.strptime(label, '%B %Y')
except ValueError:
continue
key = ref.strftime('%Y-%m')
month_buckets[key] = (row.get('overall_band') or 0.0,
row.get('__count') or 0)
trend = []
today = datetime.now().replace(day=1)
for i in range(months - 1, -1, -1):
ref = (today - timedelta(days=31 * i)).replace(day=1)
key = ref.strftime('%Y-%m')
avg_val, n = month_buckets.get(key, (0.0, 0))
trend.append({
'month': ref.strftime('%b'),
'avg': round(avg_val * 10, 1) if n else 0,
'period': key,
'n': n,
})
# --- CEFR distribution via read_group by cefr_level ---------
dist_rows = Att.read_group(
domain + [('cefr_level', '!=', False)],
fields=[],
groupby=['cefr_level'],
)
level_buckets = {}
for row in dist_rows:
label = _normalize_cefr(row.get('cefr_level'))
if label:
level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0)
palette = {
'Pre-A1': 'hsl(0, 0%, 55%)',
'A1': 'hsl(0, 72%, 51%)',
'A2': 'hsl(38, 92%, 50%)',
'B1': 'hsl(199, 89%, 48%)',
'B2': 'hsl(243, 75%, 59%)',
'C1': 'hsl(142, 71%, 45%)',
'C2': 'hsl(280, 65%, 50%)',
}
distribution = [
{'name': lvl, 'value': level_buckets.get(lvl, 0),
'color': palette[lvl]}
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
if level_buckets.get(lvl)
] or [
{'name': lvl, 'value': 0, 'color': palette[lvl]}
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
]
# --- Entity comparison via read_group by entity_id ----------
entity_rows = Att.read_group(
domain + [('overall_band', '!=', False)],
fields=['overall_band:avg'],
groupby=['entity_id'],
)
comparison = []
for row in entity_rows:
entity = row.get('entity_id') or (None, 'Unassigned')
eid, ename = (entity[0], entity[1]) if isinstance(entity, (list, tuple)) else (None, 'Unassigned')
avg_val = row.get('overall_band') or 0
comparison.append({
'entity_id': eid,
'entity_name': ename or 'Unassigned',
'avg': round(avg_val * 10, 1),
'attempts': row.get('__count') or 0,
})
comparison.sort(key=lambda r: -r['avg'])
total_considered = Att.search_count(domain)
payload = {
'by_module': by_module,
'trend': trend,
'distribution': distribution,
'comparison': comparison,
'meta': {
'attempts_considered': total_considered,
'threshold': threshold,
'months': months,
},
}
_cache_put(cache_key, payload, ttl_seconds=30)
return _json_response(payload)
except Exception as e:
_logger.exception('stats-corporate report failed')
return _error_response(str(e), 500)
# ── 3. Record ────────────────────────────────────────────────────
@http.route('/api/reports/record', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def record(self, **kw):
"""Per-user attempt history.
Query params: ``user_id`` (required for focused view; omit to see all),
``entity_id``, ``period`` (day|week|month), ``status``, pagination.
"""
try:
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw, reportable=False)
if kw.get('status'):
domain.append(('status', '=', kw['status']))
offset, limit, page = _paginate(kw)
total = Att.search_count(domain)
attempts = Att.search(domain, offset=offset, limit=limit,
order='started_at desc')
items = []
for att in attempts:
exam = att.exam_id
exam_title = ''
if exam:
# encoach.exam.custom labels its record with `title`,
# not `name`. Fall back to display_name if the field
# is ever renamed upstream.
exam_title = getattr(exam, 'title', None) \
or getattr(exam, 'display_name', '') or ''
assignment = request.env['encoach.exam.assignment'].sudo() \
.search([('exam_id', '=', exam.id),
('student_id', '=', att.student_id.id)],
limit=1) if exam and att.student_id else None
duration_min = _duration_minutes(att)
ct = _attempt_completed_at(att) or att.started_at
items.append({
'id': att.id,
'student_id': att.student_id.id or None,
'student_name': att.student_id.name if att.student_id else '',
'assignment': exam_title,
'assignment_id': assignment.id if assignment else None,
'exam': exam_title,
'exam_code': f'EX-{exam.id:03d}' if exam else '',
'exam_id': exam.id or None,
'entity_id': att.entity_id.id or None,
'entity_name': att.entity_id.name if att.entity_id else '',
'date': ct.strftime('%Y-%m-%d') if ct else None,
'started_at': att.started_at.isoformat()
if att.started_at else None,
'completed_at': (_attempt_completed_at(att).isoformat()
if _attempt_completed_at(att) else None),
'score': att.overall_band if att.overall_band else None,
'duration_min': duration_min,
'duration': f'{duration_min} min' if duration_min is not None
else '',
'status': att.status or 'in_progress',
'status_label': (att.status or 'in_progress').replace('_', ' ').title(),
'cefr_level': _normalize_cefr(att.cefr_level)
or _cefr_for_band(att.overall_band),
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': limit,
})
except Exception as e:
_logger.exception('record report failed')
return _error_response(str(e), 500)
# ── Shared picker: entities and students for report filters ──────
@http.route('/api/reports/filters', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def filters(self, **kw):
"""Lightweight list of entities + students/users we have attempts for.
Used by the filter dropdowns on all three Reports pages."""
try:
Ent = request.env['encoach.entity'].sudo()
entities = [{'id': e.id, 'name': e.name or ''}
for e in Ent.search([], order='name')]
Att = request.env['encoach.student.attempt'].sudo()
att_students = Att.search([]).mapped('student_id')
students = [{'id': u.id, 'name': u.name or u.login or '',
'login': u.login or ''}
for u in att_students.sorted('name')
if u and u.id > 0]
return _json_response({
'entities': entities,
'students': students,
})
except Exception as e:
_logger.exception('reports filters failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,286 @@
import base64
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_resource(r):
tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag']
objectives = r.learning_objective_ids if r.learning_objective_ids else r.env['encoach.learning.objective']
return {
'id': r.id,
'name': r.name or '',
'resource_type': r.type or 'document',
'type': r.type or 'document',
'subject_id': r.subject_id.id if r.subject_id else None,
'subject_name': r.subject_id.name if r.subject_id else '',
'domain_id': r.domain_id.id if r.domain_id else None,
'domain_name': r.domain_id.name if r.domain_id else '',
'topic_ids': r.topic_ids.ids if r.topic_ids else [],
'topic_names': r.topic_ids.mapped('name') if r.topic_ids else [],
'learning_objective_ids': objectives.ids,
'learning_objective_names': objectives.mapped('name'),
'tag_ids': tags.ids,
'tag_names': tags.mapped('name'),
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'url': r.url or '',
'has_file': bool(r.file),
'difficulty': r.difficulty or '',
'duration_minutes': r.duration_minutes or 0,
'author_id': r.creator_id.id if r.creator_id else None,
'author_name': r.creator_id.name if r.creator_id else '',
'review_status': r.review_status or 'approved',
'cefr_level': r.cefr_level or '',
'ai_generated': r.ai_generated,
'course_count': r.course_count or 0,
'created_at': r.create_date.isoformat() if r.create_date else '',
}
class ResourcesController(http.Controller):
@http.route('/api/resources', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_resources(self, **kw):
try:
M = request.env['encoach.resource'].sudo()
domain = []
if kw.get('subject_id'):
domain.append(('subject_id', '=', int(kw['subject_id'])))
if kw.get('topic_id'):
domain.append(('topic_ids', 'in', [int(kw['topic_id'])]))
if kw.get('tag_id'):
domain.append(('tag_ids', 'in', [int(kw['tag_id'])]))
if kw.get('resource_type'):
domain.append(('type', '=', kw['resource_type']))
if kw.get('review_status'):
domain.append(('review_status', '=', kw['review_status']))
if kw.get('date_from'):
domain.append(('create_date', '>=', kw['date_from']))
if kw.get('date_to'):
domain.append(('create_date', '<=', kw['date_to']))
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_ser_resource(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_resources')
return _error_response(str(e), 500)
@http.route('/api/resources', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_resource(self, **kw):
try:
files = request.httprequest.files
f = files.get('file')
ct = request.httprequest.content_type or ''
body = {}
if 'application/json' in ct:
body = _get_json_body()
params = {**body, **kw}
vals = {'name': params.get('name', f.filename if f else 'Resource')}
rtype = params.get('resource_type') or params.get('type')
if rtype:
vals['type'] = rtype
if params.get('subject_id'):
vals['subject_id'] = int(params['subject_id'])
if params.get('topic_id'):
vals['topic_ids'] = [(4, int(params['topic_id']))]
if params.get('topic_ids'):
ids = params['topic_ids']
if isinstance(ids, str):
ids = [int(x) for x in ids.split(',') if x.strip()]
elif isinstance(ids, list):
ids = [int(x) for x in ids]
vals['topic_ids'] = [(6, 0, ids)]
if params.get('tag_ids'):
ids = params['tag_ids']
if isinstance(ids, str):
ids = [int(x) for x in ids.split(',') if x.strip()]
elif isinstance(ids, list):
ids = [int(x) for x in ids]
vals['tag_ids'] = [(6, 0, ids)]
if params.get('domain_id'):
vals['domain_id'] = int(params['domain_id'])
if params.get('learning_objective_ids'):
ids = params['learning_objective_ids']
if isinstance(ids, str):
ids = [int(x) for x in ids.split(',') if x.strip()]
elif isinstance(ids, list):
ids = [int(x) for x in ids]
vals['learning_objective_ids'] = [(6, 0, ids)]
if params.get('url'):
vals['url'] = params['url']
if params.get('difficulty'):
vals['difficulty'] = params['difficulty']
if params.get('cefr_level'):
vals['cefr_level'] = params['cefr_level']
if f:
vals['file'] = base64.b64encode(f.read())
rec = request.env['encoach.resource'].sudo().create(vals)
return _json_response({'data': _ser_resource(rec)})
except Exception as e:
_logger.exception('create_resource')
return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_resource(self, rid, **kw):
try:
rec = request.env['encoach.resource'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'url', 'review_status', 'difficulty', 'cefr_level'):
if k in body:
vals[k] = body[k]
if 'resource_type' in body or 'type' in body:
vals['type'] = body.get('resource_type') or body.get('type')
if 'subject_id' in body:
vals['subject_id'] = int(body['subject_id']) if body['subject_id'] else False
if 'tag_ids' in body:
ids = body['tag_ids']
vals['tag_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)]
if 'domain_id' in body:
vals['domain_id'] = int(body['domain_id']) if body['domain_id'] else False
if 'topic_ids' in body:
ids = body['topic_ids']
vals['topic_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)]
elif 'topic_id' in body:
vals['topic_ids'] = [(4, int(body['topic_id']))] if body['topic_id'] else [(5,)]
if 'learning_objective_ids' in body:
ids = body['learning_objective_ids']
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)]
if vals:
rec.write(vals)
return _json_response({'data': _ser_resource(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_resource(self, rid, **kw):
try:
rec = request.env['encoach.resource'].sudo().browse(rid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>/complete', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def mark_complete(self, rid, **kw):
try:
return _json_response({'data': {'id': rid, 'completed': True}})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>/rate', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def rate_resource(self, rid, **kw):
try:
body = _get_json_body()
return _json_response({'success': True, 'rating': body.get('rating', 0)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>/download', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def download_resource(self, rid, **kw):
try:
rec = request.env['encoach.resource'].sudo().browse(rid)
if not rec.exists() or not rec.file:
return _error_response('No file', 404)
import mimetypes
ext = (rec.name or '').rsplit('.', 1)[-1].lower() if '.' in (rec.name or '') else ''
mime = mimetypes.types_map.get(f'.{ext}', 'application/octet-stream')
data = base64.b64decode(rec.file)
return request.make_response(data, [
('Content-Type', mime),
('Content-Disposition', f'attachment; filename="{rec.name}"'),
('Content-Length', str(len(data))),
])
except Exception as e:
_logger.exception('download_resource')
return _error_response(str(e), 500)
# ══════════════════════════════════════════════════════════════════
# Resource Tags CRUD
# ══════════════════════════════════════════════════════════════════
@http.route('/api/resource-tags', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_tags(self, **kw):
try:
Tag = request.env['encoach.resource.tag'].sudo()
domain = []
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
recs = Tag.search(domain, order='name')
return _json_response({
'data': [t.to_api_dict() for t in recs],
'items': [t.to_api_dict() for t in recs],
})
except Exception as e:
_logger.exception('list_tags')
return _error_response(str(e), 500)
@http.route('/api/resource-tags', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_tag(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '').strip()}
if not vals['name']:
return _error_response('name is required', 400)
if body.get('color'):
vals['color'] = body['color']
if body.get('description'):
vals['description'] = body['description']
rec = request.env['encoach.resource.tag'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()}, 201)
except Exception as e:
_logger.exception('create_tag')
return _error_response(str(e), 500)
@http.route('/api/resource-tags/<int:tid>', type='http', auth='public',
methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_tag(self, tid, **kw):
try:
rec = request.env['encoach.resource.tag'].sudo().browse(tid)
if not rec.exists():
return _error_response('Tag not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'color', 'description'):
if k in body:
vals[k] = body[k]
if vals:
rec.write(vals)
return _json_response({'data': rec.to_api_dict()})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/resource-tags/<int:tid>', type='http', auth='public',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_tag(self, tid, **kw):
try:
rec = request.env['encoach.resource.tag'].sudo().browse(tid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,112 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
class StatsController(http.Controller):
@http.route('/api/sessions', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_sessions(self, **kw):
try:
return _json_response([])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/stats', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_stats(self, **kw):
try:
env = request.env
course_count = env['op.course'].sudo().search_count([])
student_count = env['op.student'].sudo().search_count([])
faculty_count = env['op.faculty'].sudo().search_count([])
batch_count = env['op.batch'].sudo().search_count([])
return _json_response([{
'label': 'courses', 'value': course_count,
}, {
'label': 'students', 'value': student_count,
}, {
'label': 'teachers', 'value': faculty_count,
}, {
'label': 'batches', 'value': batch_count,
}])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/statistical', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_statistical(self, **kw):
try:
env = request.env
return _json_response({
'total_students': env['op.student'].sudo().search_count([]),
'total_courses': env['op.course'].sudo().search_count([]),
'total_teachers': env['op.faculty'].sudo().search_count([]),
'total_batches': env['op.batch'].sudo().search_count([]),
'exam_count': 0,
'pass_rate': 0,
'avg_score': 0,
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/stats/performance', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_performance(self, **kw):
try:
return _json_response([])
except Exception as e:
return _error_response(str(e), 500)
# ── Analytics ────────────────────────────────────────────────────
@http.route('/api/analytics/student', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def analytics_student(self, **kw):
try:
return _json_response({
'total_courses': 0,
'completed_courses': 0,
'average_score': 0,
'attendance_rate': 0,
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/analytics/class', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def analytics_class(self, **kw):
try:
return _json_response({
'total_students': 0,
'average_score': 0,
'pass_rate': 0,
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/analytics/subject', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def analytics_subject(self, **kw):
try:
return _json_response({
'subjects': [],
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/analytics/content-gaps', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def analytics_content_gaps(self, **kw):
try:
return _json_response({
'gaps': [],
})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,144 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _ser_leave(r):
return {
'id': r.id,
'request_number': r.request_number or '',
'student_id': r.student_id.id if r.student_id else 0,
'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '',
'leave_type': r.leave_type_id.name if r.leave_type_id else '',
'leave_type_id': r.leave_type_id.id if r.leave_type_id else 0,
'start_date': str(r.start_date) if r.start_date else '',
'end_date': str(r.end_date) if r.end_date else '',
'duration': r.duration or 0,
'description': r.description or '',
'state': r.state or 'draft',
'approve_date': str(r.approve_date) if r.approve_date else None,
}
class StudentLeaveController(http.Controller):
@http.route('/api/student-leaves', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_leaves(self, **kw):
try:
M = request.env['encoach.student.leave'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='create_date desc')
items = [_ser_leave(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/student-leaves', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_leave(self, **kw):
try:
body = _get_json_body()
vals = {
'start_date': body.get('start_date'),
'end_date': body.get('end_date'),
}
if body.get('student_id'):
vals['student_id'] = int(body['student_id'])
if body.get('leave_type'):
vals['leave_type_id'] = int(body['leave_type'])
if body.get('leave_type_id'):
vals['leave_type_id'] = int(body['leave_type_id'])
if body.get('description'):
vals['description'] = body['description']
rec = request.env['encoach.student.leave'].sudo().create(vals)
return _json_response(_ser_leave(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/student-leaves/<int:lid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_leave(self, lid, **kw):
try:
rec = request.env['encoach.student.leave'].sudo().browse(lid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('start_date', 'end_date', 'description'):
if k in body:
vals[k] = body[k]
if 'leave_type' in body:
vals['leave_type_id'] = int(body['leave_type'])
if vals:
rec.write(vals)
return _json_response(_ser_leave(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/student-leaves/<int:lid>/approve', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def approve_leave(self, lid, **kw):
try:
rec = request.env['encoach.student.leave'].sudo().browse(lid)
if not rec.exists():
return _error_response('Not found', 404)
from datetime import date
rec.write({'state': 'approve', 'approve_date': str(date.today()), 'approved_by': request.env.uid})
return _json_response(_ser_leave(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/student-leaves/<int:lid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_leave(self, lid, **kw):
try:
rec = request.env['encoach.student.leave'].sudo().browse(lid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/student-leaves/<int:lid>/reject', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def reject_leave(self, lid, **kw):
try:
rec = request.env['encoach.student.leave'].sudo().browse(lid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'state': 'refuse'})
return _json_response(_ser_leave(rec))
except Exception as e:
return _error_response(str(e), 500)
# ── Leave Types ──────────────────────────────────────────────────
@http.route('/api/student-leave-types', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_leave_types(self, **kw):
try:
M = request.env['encoach.student.leave.type'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit)
items = [{'id': r.id, 'name': r.name} for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/student-leave-types', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_leave_type(self, **kw):
try:
body = _get_json_body()
rec = request.env['encoach.student.leave.type'].sudo().create({'name': body.get('name', '')})
return _json_response({'id': rec.id, 'name': rec.name})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,215 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _paginate,
)
_logger = logging.getLogger(__name__)
class StudentProgressController(http.Controller):
"""
Institutional "Student Progress" view.
Aggregates per-student totals across the key OpenEduCat operational
records an admin cares about at a glance:
- total_attendance → number of op.attendance.line rows (i.e. sessions
attended or at least registered for)
- total_attendance_present → subset where present=True (best-effort, some
OpenEduCat variants store this differently)
- total_assignment → number of op.assignment.sub.line rows
- total_marksheet_line → number of op.marksheet.line rows
- total_admissions → number of op.admission rows (if the student was
admitted through the admission workflow)
The endpoint returns one row per op.student. Search (?q=) and batch
(?batch_id=) / course (?course_id=) filters are supported so the page
stays usable at scale.
"""
@http.route('/api/student-progress', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_progress(self, **kw):
try:
env = request.env
Student = env['op.student'].sudo() if 'op.student' in env else None
if Student is None:
return _json_response({'items': [], 'data': [], 'total': 0, 'page': 1, 'size': 0})
offset, limit, page = _paginate(kw)
domain = [('active', '=', True)] if 'active' in Student._fields else []
q = (kw.get('q') or '').strip()
if q:
domain += ['|',
('partner_id.name', 'ilike', q),
('gr_no', 'ilike', q)]
batch_id = kw.get('batch_id')
if batch_id:
try:
bid = int(batch_id)
if 'course_detail_ids' in Student._fields:
domain.append(('course_detail_ids.batch_id', '=', bid))
except Exception:
pass
course_id = kw.get('course_id')
if course_id:
try:
cid = int(course_id)
if 'course_detail_ids' in Student._fields:
domain.append(('course_detail_ids.course_id', '=', cid))
except Exception:
pass
total = Student.search_count(domain)
students = Student.search(domain, offset=offset, limit=limit, order='id asc')
AttLine = env['op.attendance.line'].sudo() if 'op.attendance.line' in env else None
AsgSub = env['op.assignment.sub.line'].sudo() if 'op.assignment.sub.line' in env else None
MsLine = env['op.marksheet.line'].sudo() if 'op.marksheet.line' in env else None
Admission = env['op.admission'].sudo() if 'op.admission' in env else None
items = []
for s in students:
sid = s.id
partner = s.partner_id if 'partner_id' in s._fields else False
student_name = partner.name if partner else (s.display_name or '')
total_attendance = 0
total_attendance_present = 0
if AttLine is not None:
try:
total_attendance = AttLine.search_count([('student_id', '=', sid)])
if 'present' in AttLine._fields:
total_attendance_present = AttLine.search_count(
[('student_id', '=', sid), ('present', '=', True)]
)
elif 'attendance' in AttLine._fields:
total_attendance_present = AttLine.search_count(
[('student_id', '=', sid), ('attendance', '=', True)]
)
except Exception:
pass
total_assignment = 0
if AsgSub is not None:
try:
total_assignment = AsgSub.search_count([('student_id', '=', sid)])
except Exception:
pass
total_marksheet_line = 0
avg_marks = None
if MsLine is not None:
try:
lines = MsLine.search([('student_id', '=', sid)])
total_marksheet_line = len(lines)
if lines and 'marks' in MsLine._fields:
marks = [float(l.marks or 0) for l in lines]
if marks:
avg_marks = round(sum(marks) / len(marks), 2)
except Exception:
pass
total_admissions = 0
if Admission is not None:
try:
total_admissions = Admission.search_count([('student_id', '=', sid)])
except Exception:
pass
attendance_rate = None
if total_attendance:
attendance_rate = round(
(total_attendance_present / total_attendance) * 100, 1
)
items.append({
'id': sid,
'student_id': sid,
'student_name': student_name,
'gr_no': getattr(s, 'gr_no', '') or '',
'total_attendance': total_attendance,
'total_attendance_present': total_attendance_present,
'attendance_rate': attendance_rate,
'total_assignment': total_assignment,
'total_marksheet_line': total_marksheet_line,
'avg_marks': avg_marks,
'total_admissions': total_admissions,
})
return _json_response({
'items': items,
'data': items,
'total': total,
'page': page,
'size': limit,
})
except Exception as e:
_logger.exception('list_progress')
return _error_response(str(e), 500)
@http.route('/api/student-progress/<int:sid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_progress(self, sid, **kw):
"""Detailed per-student breakdown (lines, not just totals)."""
try:
env = request.env
if 'op.student' not in env:
return _error_response('op.student model not installed', 404)
student = env['op.student'].sudo().browse(sid)
if not student.exists():
return _error_response('Student not found', 404)
partner = student.partner_id if 'partner_id' in student._fields else False
out = {
'id': student.id,
'student_name': partner.name if partner else (student.display_name or ''),
'gr_no': getattr(student, 'gr_no', '') or '',
'attendance': [],
'assignments': [],
'marksheets': [],
}
if 'op.attendance.line' in env:
AL = env['op.attendance.line'].sudo()
lines = AL.search([('student_id', '=', sid)], limit=100, order='id desc')
for l in lines:
present = None
if 'present' in AL._fields:
present = bool(l.present)
elif 'attendance' in AL._fields:
present = bool(l.attendance)
out['attendance'].append({
'id': l.id,
'sheet': l.attendance_id.display_name if 'attendance_id' in l._fields and l.attendance_id else '',
'present': present,
})
if 'op.assignment.sub.line' in env:
AS = env['op.assignment.sub.line'].sudo()
lines = AS.search([('student_id', '=', sid)], limit=100, order='id desc')
for l in lines:
out['assignments'].append({
'id': l.id,
'assignment': l.assignment_id.display_name if 'assignment_id' in l._fields and l.assignment_id else '',
'marks': float(getattr(l, 'marks', 0) or 0),
'state': getattr(l, 'state', '') or '',
})
if 'op.marksheet.line' in env:
ML = env['op.marksheet.line'].sudo()
lines = ML.search([('student_id', '=', sid)], limit=100, order='id desc')
for l in lines:
out['marksheets'].append({
'id': l.id,
'marksheet': l.marksheet_reg_id.display_name if 'marksheet_reg_id' in l._fields and l.marksheet_reg_id else '',
'marks': float(getattr(l, 'marks', 0) or 0),
'grade': getattr(l, 'grade', '') or '',
})
return _json_response(out)
except Exception as e:
_logger.exception('get_progress')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,176 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _ser_ticket(t):
return {
'id': t.id,
'subject': t.subject or '',
'description': t.description or '',
'type': t.type or 'support',
'status': t.status or 'open',
'source': t.source or 'platform',
'page_context': t.page_context or '',
'reporter_id': t.reporter_id.id if t.reporter_id else None,
'reporter_name': t.reporter_id.name if t.reporter_id else '',
'assignee_id': t.assignee_id.id if t.assignee_id else None,
'assignee_name': t.assignee_id.name if t.assignee_id else '',
'entity_id': t.entity_id.id if t.entity_id else None,
'entity_name': t.entity_id.name if t.entity_id else '',
'created_at': t.create_date.isoformat() if t.create_date else '',
'resolved_at': t.resolved_at.isoformat() if t.resolved_at else '',
}
class TicketsController(http.Controller):
@http.route('/api/tickets', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_tickets(self, **kw):
try:
Ticket = request.env['encoach.ticket'].sudo()
domain = []
status = kw.get('status')
if status and status != 'all':
domain.append(('status', '=', status))
t = kw.get('type')
if t and t != 'all':
domain.append(('type', '=', t))
src = kw.get('source')
if src and src != 'all':
domain.append(('source', '=', src))
assignee = kw.get('assignee_id')
if assignee:
try:
domain.append(('assignee_id', '=', int(assignee)))
except (TypeError, ValueError):
pass
search = kw.get('search') or kw.get('q')
if search:
domain.append('|')
domain.append(('subject', 'ilike', search))
domain.append(('description', 'ilike', search))
try:
size = min(int(kw.get('size') or 50), 200)
except (TypeError, ValueError):
size = 50
try:
page = max(int(kw.get('page') or 1), 1)
except (TypeError, ValueError):
page = 1
total = Ticket.search_count(domain)
recs = Ticket.search(domain, limit=size, offset=(page - 1) * size)
items = [_ser_ticket(t) for t in recs]
return _json_response({
'data': items,
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('list_tickets')
return _error_response(str(e), 500)
@http.route('/api/tickets', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_ticket(self, **kw):
try:
body = _get_json_body()
if not body.get('subject'):
return _error_response('subject is required', 400)
vals = {
'subject': body['subject'],
'description': body.get('description') or '',
'type': body.get('type') or 'support',
'status': body.get('status') or 'open',
'source': body.get('source') or 'platform',
'page_context': body.get('page_context') or '',
}
if body.get('assignee_id'):
vals['assignee_id'] = int(body['assignee_id'])
if body.get('entity_id'):
vals['entity_id'] = int(body['entity_id'])
# reporter defaults to current user via the model default.
rec = request.env['encoach.ticket'].sudo().create(vals)
return _json_response(_ser_ticket(rec))
except Exception as e:
_logger.exception('create_ticket')
return _error_response(str(e), 500)
@http.route('/api/tickets/<int:tid>', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def get_ticket(self, tid, **kw):
try:
rec = request.env['encoach.ticket'].sudo().browse(tid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_ticket(rec))
except Exception as e:
_logger.exception('get_ticket')
return _error_response(str(e), 500)
@http.route('/api/tickets/<int:tid>', type='http', auth='public',
methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_ticket(self, tid, **kw):
try:
from odoo import fields as odoo_fields
body = _get_json_body()
rec = request.env['encoach.ticket'].sudo().browse(tid)
if not rec.exists():
return _error_response('Not found', 404)
vals = {}
for k in ('subject', 'description', 'type', 'status', 'source', 'page_context'):
if k in body:
vals[k] = body[k]
if 'assignee_id' in body:
vals['assignee_id'] = int(body['assignee_id']) if body['assignee_id'] else False
if 'entity_id' in body:
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
# Auto-stamp resolved_at when transitioning to resolved/closed.
if vals.get('status') in ('resolved', 'closed') and not rec.resolved_at:
vals['resolved_at'] = odoo_fields.Datetime.now()
if vals:
rec.write(vals)
return _json_response(_ser_ticket(rec))
except Exception as e:
_logger.exception('update_ticket')
return _error_response(str(e), 500)
@http.route('/api/tickets/<int:tid>', type='http', auth='public',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_ticket(self, tid, **kw):
try:
rec = request.env['encoach.ticket'].sudo().browse(tid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_ticket')
return _error_response(str(e), 500)
@http.route('/api/tickets/assignedToUser', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def assigned_to_user(self, **kw):
try:
uid = request.env.user.id
recs = request.env['encoach.ticket'].sudo().search([
('assignee_id', '=', uid),
], limit=100)
items = [_ser_ticket(t) for t in recs]
return _json_response({'data': items, 'items': items, 'total': len(items)})
except Exception as e:
_logger.exception('assigned_to_user')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,75 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _ser_session(s):
timing = s.timing_id if hasattr(s, 'timing_id') and s.timing_id else None
return {
'id': s.id,
'course_id': s.course_id.id if hasattr(s, 'course_id') and s.course_id else 0,
'course_name': s.course_id.name if hasattr(s, 'course_id') and s.course_id else '',
'teacher_name': s.faculty_id.partner_id.name if hasattr(s, 'faculty_id') and s.faculty_id and s.faculty_id.partner_id else '',
'batch_name': s.batch_id.name if hasattr(s, 'batch_id') and s.batch_id else '',
'day': getattr(s, 'day', '') or '',
'start_time': str(timing.hour) + ':' + str(int(timing.minute)).zfill(2) if timing and hasattr(timing, 'hour') else getattr(s, 'start_datetime', '') or '',
'end_time': '',
'room': getattr(s, 'classroom_id', False) and s.classroom_id.name or '',
}
class TimetableController(http.Controller):
@http.route('/api/timetable', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_timetable(self, **kw):
try:
M = request.env['op.session'].sudo()
domain = []
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('batch_id'):
domain.append(('batch_id', '=', int(kw['batch_id'])))
recs = M.search(domain, limit=200, order='id desc')
data = [_ser_session(r) for r in recs]
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_timetable')
return _error_response(str(e), 500)
@http.route('/api/timetable', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_timetable(self, **kw):
try:
body = _get_json_body()
vals = {}
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
if body.get('faculty_id'):
vals['faculty_id'] = int(body['faculty_id'])
if body.get('subject_id'):
vals['subject_id'] = int(body['subject_id'])
if body.get('day'):
vals['day'] = body['day']
rec = request.env['op.session'].sudo().create(vals)
return _json_response(_ser_session(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/timetable/<int:sid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_timetable(self, sid, **kw):
try:
rec = request.env['op.session'].sudo().browse(sid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -0,0 +1,372 @@
"""Training endpoints: vocabulary + grammar library + per-user progress."""
import logging
from odoo import http, fields as odoo_fields
from odoo.exceptions import ValidationError, UserError
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _user_error_status(e):
"""Translate Odoo validation errors into HTTP 400 instead of 500."""
return 400 if isinstance(e, (ValidationError, UserError)) else 500
# ── Serializers ─────────────────────────────────────────────────────────
def _ser_vocab(v, progress=None):
return {
'id': v.id,
'word': v.word or '',
'meaning': v.meaning or '',
'example_sentence': v.example_sentence or '',
'level': v.level or 'B1',
'part_of_speech': v.part_of_speech or 'noun',
'category': v.category or 'general',
'active': v.active,
'learners_count': v.learners_count or 0,
'completion_count': v.completion_count or 0,
'completed': bool(progress and progress.completed),
'mastery': (progress.mastery if progress else 'learning'),
'last_reviewed': (progress.last_reviewed.isoformat() if progress and progress.last_reviewed else ''),
}
def _ser_rule(r, progress=None):
return {
'id': r.id,
'name': r.name or '',
'description': r.description or '',
'example': r.example or '',
'level': r.level or 'B1',
'category': r.category or 'general',
'active': r.active,
'learners_count': r.learners_count or 0,
'completion_count': r.completion_count or 0,
'completed': bool(progress and progress.completed),
'last_reviewed': (progress.last_reviewed.isoformat() if progress and progress.last_reviewed else ''),
}
def _domain_from_kw(kw, text_fields):
domain = []
if kw.get('level') and kw['level'] != 'all':
domain.append(('level', '=', kw['level']))
if kw.get('category') and kw['category'] != 'all':
domain.append(('category', '=', kw['category']))
active = kw.get('active')
if active in ('true', 'false'):
domain.append(('active', '=', active == 'true'))
search = kw.get('search') or kw.get('q')
if search and text_fields:
terms = [(f, 'ilike', search) for f in text_fields]
if len(terms) > 1:
domain.extend(['|'] * (len(terms) - 1))
domain.extend(terms)
return domain
def _pager(kw):
try:
size = min(int(kw.get('size') or 100), 500)
except (TypeError, ValueError):
size = 100
try:
page = max(int(kw.get('page') or 1), 1)
except (TypeError, ValueError):
page = 1
return size, page
# ── Vocabulary ──────────────────────────────────────────────────────────
class VocabularyController(http.Controller):
@http.route('/api/training/vocabulary', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_vocab(self, **kw):
try:
Vocab = request.env['encoach.vocab.item'].sudo()
domain = _domain_from_kw(kw, ['word', 'meaning', 'category'])
size, page = _pager(kw)
total = Vocab.search_count(domain)
recs = Vocab.search(domain, limit=size, offset=(page - 1) * size)
Progress = request.env['encoach.vocab.progress'].sudo()
uid = request.env.user.id
prog_map = {
p.vocab_id.id: p for p in Progress.search([
('user_id', '=', uid),
('vocab_id', 'in', recs.ids),
])
} if recs else {}
items = [_ser_vocab(v, prog_map.get(v.id)) for v in recs]
completed = sum(1 for i in items if i['completed'])
return _json_response({
'data': items, 'items': items, 'total': total,
'summary': {
'total': total,
'completed': completed,
'remaining': total - completed,
'completion_rate': round((completed / total) * 100, 1) if total else 0.0,
},
'page': page, 'size': size,
})
except Exception as e:
_logger.exception('list_vocab')
return _error_response(str(e), 500)
@http.route('/api/training/vocabulary', type='http', auth='public',
methods=['POST'], csrf=False)
@jwt_required
def create_vocab(self, **kw):
try:
body = _get_json_body()
if not body.get('word') or not body.get('meaning'):
return _error_response('word and meaning are required', 400)
vals = {
'word': body['word'].strip(),
'meaning': body['meaning'].strip(),
'example_sentence': body.get('example_sentence') or '',
'level': body.get('level') or 'B1',
'part_of_speech': body.get('part_of_speech') or 'noun',
'category': body.get('category') or 'general',
'active': body.get('active', True),
}
rec = request.env['encoach.vocab.item'].sudo().create(vals)
return _json_response(_ser_vocab(rec))
except Exception as e:
_logger.exception('create_vocab')
return _error_response(str(e), _user_error_status(e))
@http.route('/api/training/vocabulary/<int:vid>', type='http',
auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_vocab(self, vid, **kw):
rec = request.env['encoach.vocab.item'].sudo().browse(vid)
if not rec.exists():
return _error_response('Not found', 404)
prog = request.env['encoach.vocab.progress'].sudo().search([
('user_id', '=', request.env.user.id),
('vocab_id', '=', rec.id),
], limit=1)
return _json_response(_ser_vocab(rec, prog))
@http.route('/api/training/vocabulary/<int:vid>', type='http',
auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_vocab(self, vid, **kw):
try:
body = _get_json_body()
rec = request.env['encoach.vocab.item'].sudo().browse(vid)
if not rec.exists():
return _error_response('Not found', 404)
vals = {}
for k in ('word', 'meaning', 'example_sentence', 'level',
'part_of_speech', 'category'):
if k in body:
vals[k] = body[k]
if 'active' in body:
vals['active'] = bool(body['active'])
if vals:
rec.write(vals)
return _json_response(_ser_vocab(rec))
except Exception as e:
_logger.exception('update_vocab')
return _error_response(str(e), _user_error_status(e))
@http.route('/api/training/vocabulary/<int:vid>', type='http',
auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_vocab(self, vid, **kw):
try:
rec = request.env['encoach.vocab.item'].sudo().browse(vid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_vocab')
return _error_response(str(e), 500)
@http.route('/api/training/vocabulary/<int:vid>/progress',
type='http', auth='public',
methods=['POST', 'PATCH'], csrf=False)
@jwt_required
def set_vocab_progress(self, vid, **kw):
try:
body = _get_json_body()
rec = request.env['encoach.vocab.item'].sudo().browse(vid)
if not rec.exists():
return _error_response('Not found', 404)
Progress = request.env['encoach.vocab.progress'].sudo()
uid = request.env.user.id
prog = Progress.search([
('user_id', '=', uid),
('vocab_id', '=', rec.id),
], limit=1)
vals = {
'user_id': uid,
'vocab_id': rec.id,
'last_reviewed': odoo_fields.Datetime.now(),
}
if 'completed' in body:
vals['completed'] = bool(body['completed'])
if body.get('mastery') in ('learning', 'familiar', 'mastered'):
vals['mastery'] = body['mastery']
if prog:
vals['review_count'] = (prog.review_count or 0) + 1
prog.write(vals)
else:
vals['review_count'] = 1
prog = Progress.create(vals)
return _json_response(_ser_vocab(rec, prog))
except Exception as e:
_logger.exception('set_vocab_progress')
return _error_response(str(e), 500)
# ── Grammar ─────────────────────────────────────────────────────────────
class GrammarController(http.Controller):
@http.route('/api/training/grammar', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_grammar(self, **kw):
try:
Rule = request.env['encoach.grammar.rule'].sudo()
domain = _domain_from_kw(kw, ['name', 'description', 'category'])
size, page = _pager(kw)
total = Rule.search_count(domain)
recs = Rule.search(domain, limit=size, offset=(page - 1) * size)
Progress = request.env['encoach.grammar.progress'].sudo()
uid = request.env.user.id
prog_map = {
p.rule_id.id: p for p in Progress.search([
('user_id', '=', uid),
('rule_id', 'in', recs.ids),
])
} if recs else {}
items = [_ser_rule(r, prog_map.get(r.id)) for r in recs]
completed = sum(1 for i in items if i['completed'])
return _json_response({
'data': items, 'items': items, 'total': total,
'summary': {
'total': total,
'completed': completed,
'remaining': total - completed,
'completion_rate': round((completed / total) * 100, 1) if total else 0.0,
},
'page': page, 'size': size,
})
except Exception as e:
_logger.exception('list_grammar')
return _error_response(str(e), 500)
@http.route('/api/training/grammar', type='http', auth='public',
methods=['POST'], csrf=False)
@jwt_required
def create_rule(self, **kw):
try:
body = _get_json_body()
if not body.get('name') or not body.get('description'):
return _error_response('name and description are required', 400)
vals = {
'name': body['name'].strip(),
'description': body['description'].strip(),
'example': body.get('example') or '',
'level': body.get('level') or 'B1',
'category': body.get('category') or 'general',
'active': body.get('active', True),
}
rec = request.env['encoach.grammar.rule'].sudo().create(vals)
return _json_response(_ser_rule(rec))
except Exception as e:
_logger.exception('create_rule')
return _error_response(str(e), _user_error_status(e))
@http.route('/api/training/grammar/<int:rid>', type='http',
auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_rule(self, rid, **kw):
rec = request.env['encoach.grammar.rule'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
prog = request.env['encoach.grammar.progress'].sudo().search([
('user_id', '=', request.env.user.id),
('rule_id', '=', rec.id),
], limit=1)
return _json_response(_ser_rule(rec, prog))
@http.route('/api/training/grammar/<int:rid>', type='http',
auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_rule(self, rid, **kw):
try:
body = _get_json_body()
rec = request.env['encoach.grammar.rule'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
vals = {}
for k in ('name', 'description', 'example', 'level', 'category'):
if k in body:
vals[k] = body[k]
if 'active' in body:
vals['active'] = bool(body['active'])
if vals:
rec.write(vals)
return _json_response(_ser_rule(rec))
except Exception as e:
_logger.exception('update_rule')
return _error_response(str(e), _user_error_status(e))
@http.route('/api/training/grammar/<int:rid>', type='http',
auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_rule(self, rid, **kw):
try:
rec = request.env['encoach.grammar.rule'].sudo().browse(rid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_rule')
return _error_response(str(e), 500)
@http.route('/api/training/grammar/<int:rid>/progress',
type='http', auth='public',
methods=['POST', 'PATCH'], csrf=False)
@jwt_required
def set_rule_progress(self, rid, **kw):
try:
body = _get_json_body()
rec = request.env['encoach.grammar.rule'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
Progress = request.env['encoach.grammar.progress'].sudo()
uid = request.env.user.id
prog = Progress.search([
('user_id', '=', uid),
('rule_id', '=', rec.id),
], limit=1)
vals = {
'user_id': uid,
'rule_id': rec.id,
'last_reviewed': odoo_fields.Datetime.now(),
}
if 'completed' in body:
vals['completed'] = bool(body['completed'])
if prog:
vals['review_count'] = (prog.review_count or 0) + 1
prog.write(vals)
else:
vals['review_count'] = 1
prog = Progress.create(vals)
return _json_response(_ser_rule(rec, prog))
except Exception as e:
_logger.exception('set_rule_progress')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,542 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _detect_user_type(u):
"""Detect effective user type from user_type field, groups, and linked records."""
ut = getattr(u, 'user_type', '') or ''
if ut == 'admin':
return 'admin'
if ut == 'teacher':
return 'teacher'
if u.has_group('base.group_system') or u.has_group('base.group_erp_manager'):
return 'admin'
is_faculty = bool(u.env['op.faculty'].sudo().search([('user_id', '=', u.id)], limit=1))
if is_faculty:
return 'teacher'
is_student = bool(u.env['op.student'].sudo().search([('user_id', '=', u.id)], limit=1))
if is_student:
return 'student'
if u.has_group('base.group_portal'):
return 'student'
return ut or 'user'
def _ser_user(u):
role_ids = u.role_ids.ids if hasattr(u, 'role_ids') else []
roles = [{'id': r.id, 'name': r.name or ''} for r in u.role_ids] if hasattr(u, 'role_ids') else []
return {
'id': u.id,
'name': u.name or '',
'email': u.email or u.login or '',
'login': u.login or '',
'user_type': _detect_user_type(u),
'is_verified': True,
'active': u.active,
'phone': u.phone or '',
'gender': getattr(u, 'gender', '') or '',
'role_ids': role_ids,
'roles': roles,
'effective_permission_count': len(u.get_all_permissions()) if hasattr(u, 'get_all_permissions') else 0,
}
def _ser_role(r):
"""Full role serialization for the frontend Role type."""
entity = r.entity_id
return {
'id': r.id,
'name': r.name or '',
'code': getattr(r, 'code', '') or '',
'description': getattr(r, 'description', '') or '',
'entity_id': entity.id if entity else None,
'entity_name': entity.name if entity else '',
'permission_ids': r.permission_ids.ids,
'permissions': [
{
'id': p.id,
'name': getattr(p, 'name', '') or p.code or '',
'code': p.code or '',
'description': getattr(p, 'description', '') or '',
'category': getattr(p, 'topic', '') or '',
}
for p in r.permission_ids
],
'user_count': getattr(r, 'user_count', 0) or len(r.user_ids) if hasattr(r, 'user_ids') else 0,
}
def _ser_perm(p):
return {
'id': p.id,
'name': getattr(p, 'name', '') or p.code or '',
'code': p.code or '',
'description': getattr(p, 'description', '') or '',
'category': getattr(p, 'topic', '') or '',
}
class UsersRolesController(http.Controller):
# ── Users ────────────────────────────────────────────────────────
@http.route('/api/users/list', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_users(self, **kw):
try:
M = request.env['res.users'].sudo()
domain = [('share', '=', False)]
if kw.get('type'):
domain.append(('user_type', '=', kw['type']))
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_ser_user(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_users')
return _error_response(str(e), 500)
@http.route('/api/users/<int:uid_param>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_user(self, uid_param, **kw):
try:
rec = request.env['res.users'].sudo().browse(uid_param)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response({'data': _ser_user(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/users/update', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_user(self, **kw):
try:
body = _get_json_body()
uid_val = body.get('id')
if not uid_val:
return _error_response('Missing id', 400)
rec = request.env['res.users'].sudo().browse(int(uid_val))
if not rec.exists():
return _error_response('Not found', 404)
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'email' in body:
vals['email'] = body['email']
if 'phone' in body:
vals['phone'] = body['phone']
if vals:
rec.write(vals)
return _json_response({'data': _ser_user(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/users/create', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_user(self, **kw):
try:
body = _get_json_body()
vals = {
'name': body.get('name', ''),
'login': body.get('email', body.get('login', '')),
'email': body.get('email', ''),
'password': body.get('password', 'user123'),
}
rec = request.env['res.users'].sudo().create(vals)
return _json_response({'data': _ser_user(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/batch_users', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_batch_users(self, **kw):
try:
body = _get_json_body()
users = body.get('users', [])
created = []
for u in users:
vals = {
'name': u.get('name', ''),
'login': u.get('email', u.get('login', '')),
'email': u.get('email', ''),
'password': u.get('password', 'user123'),
}
rec = request.env['res.users'].sudo().create(vals)
created.append(rec.id)
return _json_response({'success': True, 'created_ids': created})
except Exception as e:
return _error_response(str(e), 500)
# ── Roles ────────────────────────────────────────────────────────
@http.route('/api/roles', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_roles(self, **kw):
try:
M = request.env['encoach.role'].sudo()
domain = []
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
if kw.get('entity_id'):
domain.append(('entity_id', '=', int(kw['entity_id'])))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = [_ser_role(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/roles/<int:rid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_role(self, rid, **kw):
try:
rec = request.env['encoach.role'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response({'data': _ser_role(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/roles', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_role(self, **kw):
try:
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('Name is required', 400)
entity_id = body.get('entity_id')
if not entity_id:
entity = request.env['encoach.entity'].sudo().search([], limit=1)
entity_id = entity.id if entity else False
if not entity_id:
return _error_response('No entity available for role', 400)
vals = {
'name': name,
'entity_id': int(entity_id),
}
if body.get('code'):
vals['code'] = body['code']
if body.get('description'):
vals['description'] = body['description']
if body.get('permission_ids'):
vals['permission_ids'] = [(6, 0, [int(p) for p in body['permission_ids']])]
rec = request.env['encoach.role'].sudo().create(vals)
return _json_response({'data': _ser_role(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/roles/<int:rid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_role(self, rid, **kw):
try:
rec = request.env['encoach.role'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'code' in body:
vals['code'] = body['code']
if 'description' in body:
vals['description'] = body['description']
if vals:
rec.write(vals)
return _json_response({'data': _ser_role(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/roles/<int:rid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_role(self, rid, **kw):
try:
rec = request.env['encoach.role'].sudo().browse(rid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/roles/<int:rid>/permissions', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_role_permissions(self, rid, **kw):
try:
rec = request.env['encoach.role'].sudo().browse(rid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
perm_ids = body.get('permission_ids', [])
rec.write({'permission_ids': [(6, 0, [int(p) for p in perm_ids])]})
return _json_response({'data': _ser_role(rec)})
except Exception as e:
return _error_response(str(e), 500)
# ── Permissions ──────────────────────────────────────────────────
@http.route('/api/permissions', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_permissions(self, **kw):
try:
M = request.env['encoach.permission'].sudo()
domain = []
if kw.get('category'):
domain.append(('topic', '=', kw['category']))
if kw.get('search'):
domain.append(('code', 'ilike', kw['search']))
recs = M.search(domain, limit=500, order='topic, code')
items = [_ser_perm(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/permissions', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_permission(self, **kw):
try:
body = _get_json_body()
code = body.get('code', body.get('name', '')).strip()
if not code:
return _error_response('Code is required', 400)
vals = {'code': code}
if body.get('name'):
vals['name'] = body['name']
if body.get('description'):
vals['description'] = body['description']
if body.get('topic') or body.get('category'):
vals['topic'] = body.get('topic') or body.get('category', '')
rec = request.env['encoach.permission'].sudo().create(vals)
return _json_response({'data': _ser_perm(rec)})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/permissions/<int:pid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_permission(self, pid, **kw):
try:
rec = request.env['encoach.permission'].sudo().browse(pid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
# ── Authority Matrix ─────────────────────────────────────────────
@http.route('/api/authority-matrix', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_authority_matrix(self, **kw):
try:
roles = request.env['encoach.role'].sudo().search([])
perms = request.env['encoach.permission'].sudo().search([], order='topic, code')
categories = {}
for p in perms:
cat = p.topic or 'General'
categories.setdefault(cat, []).append(_ser_perm(p))
role_list = []
for r in roles:
role_list.append({
'id': r.id,
'name': r.name or '',
'description': getattr(r, 'description', '') or '',
'granted_permission_ids': r.permission_ids.ids,
})
return _json_response({
'roles': role_list,
'permissions': [_ser_perm(p) for p in perms],
'categories': categories,
'matrix': {str(r.id): r.permission_ids.ids for r in roles},
'total_roles': len(roles),
'total_permissions': len(perms),
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/authority-matrix/toggle', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def toggle_matrix(self, **kw):
try:
body = _get_json_body()
role = request.env['encoach.role'].sudo().browse(int(body.get('role_id', 0)))
perm_id = int(body.get('permission_id', 0))
if not role.exists():
return _error_response('Role not found', 404)
current = role.permission_ids.ids
if perm_id in current:
role.write({'permission_ids': [(3, perm_id)]})
granted = False
else:
role.write({'permission_ids': [(4, perm_id)]})
granted = True
return _json_response({'role_id': role.id, 'permission_id': perm_id, 'granted': granted})
except Exception as e:
return _error_response(str(e), 500)
# ── User-Role Assignment ─────────────────────────────────────────
@http.route('/api/users/with-roles', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_users_with_roles(self, **kw):
try:
M = request.env['res.users'].sudo()
domain = [('share', '=', False)]
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
if kw.get('role_id'):
domain.append(('role_ids', 'in', [int(kw['role_id'])]))
offset, limit, page = _paginate(kw)
total = M.search_count(domain)
recs = M.search(domain, offset=offset, limit=limit, order='id desc')
items = []
for u in recs:
role_ids = u.role_ids.ids if hasattr(u, 'role_ids') else []
all_perms = u.get_all_permissions() if hasattr(u, 'get_all_permissions') else u.role_ids.mapped('permission_ids')
items.append({
'id': u.id,
'name': u.name or '',
'login': u.login or '',
'email': u.email or u.login or '',
'active': u.active,
'role_ids': role_ids,
'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids],
'effective_permission_count': len(all_perms),
})
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_users_with_roles')
return _error_response(str(e), 500)
@http.route('/api/users/<int:uid_param>/roles', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_user_roles(self, uid_param, **kw):
try:
user = request.env['res.users'].sudo().browse(uid_param)
if not user.exists():
return _error_response('User not found', 404)
all_roles = request.env['encoach.role'].sudo().search([])
assigned_ids = user.role_ids.ids if hasattr(user, 'role_ids') else []
all_perms = user.get_all_permissions() if hasattr(user, 'get_all_permissions') else request.env['encoach.permission']
roles_detail = []
for r in all_roles:
roles_detail.append({
'id': r.id,
'name': r.name or '',
'description': getattr(r, 'description', '') or '',
'assigned': r.id in assigned_ids,
'permission_count': len(r.permission_ids),
})
return _json_response({
'user': {
'id': user.id,
'name': user.name or '',
'login': user.login or '',
'email': user.email or '',
},
'roles': roles_detail,
'effective_permissions': [_ser_perm(p) for p in all_perms],
})
except Exception as e:
_logger.exception('get_user_roles')
return _error_response(str(e), 500)
@http.route('/api/users/<int:uid_param>/roles', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_user_roles(self, uid_param, **kw):
try:
user = request.env['res.users'].sudo().browse(uid_param)
if not user.exists():
return _error_response('User not found', 404)
body = _get_json_body()
role_ids = body.get('role_ids', [])
user.write({'role_ids': [(6, 0, [int(r) for r in role_ids])]})
return _json_response({
'success': True,
'user_id': user.id,
'role_ids': user.role_ids.ids,
})
except Exception as e:
_logger.exception('update_user_roles')
return _error_response(str(e), 500)
@http.route('/api/users/<int:uid_param>/roles/toggle', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def toggle_user_role(self, uid_param, **kw):
try:
user = request.env['res.users'].sudo().browse(uid_param)
if not user.exists():
return _error_response('User not found', 404)
body = _get_json_body()
role_id = int(body.get('role_id', 0))
if not role_id:
return _error_response('Missing role_id', 400)
current = user.role_ids.ids if hasattr(user, 'role_ids') else []
if role_id in current:
user.write({'role_ids': [(3, role_id)]})
assigned = False
else:
user.write({'role_ids': [(4, role_id)]})
assigned = True
return _json_response({
'user_id': user.id,
'role_id': role_id,
'assigned': assigned,
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/user-authority-matrix', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_user_authority_matrix(self, **kw):
try:
users = request.env['res.users'].sudo().search([
('share', '=', False),
], order='name', limit=200)
perms = request.env['encoach.permission'].sudo().search([], order='topic, code')
categories = {}
for p in perms:
cat = p.topic or 'General'
categories.setdefault(cat, []).append(_ser_perm(p))
user_list = []
for u in users:
all_perms = u.get_all_permissions() if hasattr(u, 'get_all_permissions') else u.role_ids.mapped('permission_ids')
user_list.append({
'id': u.id,
'name': u.name or '',
'login': u.login or '',
'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids],
'granted_permission_ids': all_perms.ids,
})
return _json_response({
'users': user_list,
'permissions': [_ser_perm(p) for p in perms],
'categories': categories,
'total_users': len(user_list),
'total_permissions': len(perms),
})
except Exception as e:
_logger.exception('get_user_authority_matrix')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,12 @@
from . import lesson
from . import student_leave
from . import gradebook
from . import communication
from . import courseware
from . import notification
from . import faq
from . import course_ext
from . import asset
from . import ticket
from . import training
from . import paymob_order

View File

@@ -0,0 +1,13 @@
from odoo import fields, models
class EncoachAsset(models.Model):
_name = 'encoach.asset'
_description = 'Institutional Asset (lightweight)'
_order = 'id desc'
name = fields.Char('Name', required=True)
code = fields.Char('Code')
facility_id = fields.Many2one('op.facility', string='Facility')
description = fields.Text('Description')
active = fields.Boolean('Active', default=True)

View File

@@ -0,0 +1,69 @@
from odoo import models, fields
class EncoachDiscussionBoard(models.Model):
_name = 'encoach.discussion.board'
_description = 'Discussion Board'
name = fields.Char(required=True)
course_id = fields.Many2one('op.course', ondelete='cascade')
batch_id = fields.Many2one('op.batch', ondelete='set null')
chapter_id = fields.Many2one('encoach.course.chapter', ondelete='set null')
is_enabled = fields.Boolean(default=True)
allow_student_posts = fields.Boolean(default=True)
post_ids = fields.One2many('encoach.discussion.post', 'board_id')
post_count = fields.Integer(compute='_compute_post_count', store=True)
def _compute_post_count(self):
for rec in self:
rec.post_count = len(rec.post_ids)
class EncoachDiscussionPost(models.Model):
_name = 'encoach.discussion.post'
_description = 'Discussion Post'
_order = 'create_date desc'
board_id = fields.Many2one('encoach.discussion.board', required=True, ondelete='cascade')
parent_id = fields.Many2one('encoach.discussion.post', string='Parent', ondelete='cascade')
child_ids = fields.One2many('encoach.discussion.post', 'parent_id', string='Replies')
author_id = fields.Many2one('res.users', required=True, ondelete='cascade')
title = fields.Char()
content = fields.Text(required=True)
is_pinned = fields.Boolean(default=False)
is_resolved = fields.Boolean(default=False)
class EncoachAnnouncement(models.Model):
_name = 'encoach.announcement'
_description = 'Announcement'
_order = 'create_date desc'
title = fields.Char(required=True)
content = fields.Text(required=True)
author_id = fields.Many2one('res.users', required=True, ondelete='cascade')
course_id = fields.Many2one('op.course', ondelete='set null')
batch_id = fields.Many2one('op.batch', ondelete='set null')
priority = fields.Selection([
('normal', 'Normal'),
('important', 'Important'),
('urgent', 'Urgent'),
], default='normal')
is_published = fields.Boolean(default=False)
published_at = fields.Datetime()
expires_at = fields.Datetime()
send_email = fields.Boolean(default=False)
class EncoachMessage(models.Model):
_name = 'encoach.message'
_description = 'Direct Message'
_order = 'create_date desc'
sender_id = fields.Many2one('res.users', required=True, ondelete='cascade')
recipient_id = fields.Many2one('res.users', required=True, ondelete='cascade')
subject = fields.Char(required=True)
content = fields.Text(required=True)
is_read = fields.Boolean(default=False)
read_at = fields.Datetime()
send_email_copy = fields.Boolean(default=False)

View File

@@ -0,0 +1,56 @@
from odoo import models, fields, api
class OpCourseExt(models.Model):
_inherit = 'op.course'
description = fields.Text('Description')
max_capacity = fields.Integer('Max Capacity', default=30)
encoach_subject_id = fields.Many2one(
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
)
encoach_topic_ids = fields.Many2many(
'encoach.topic', 'op_course_encoach_topic_rel',
'course_id', 'topic_id', string='Topics',
)
learning_objective_ids = fields.Many2many(
'encoach.learning.objective', 'op_course_learning_obj_rel',
'course_id', 'objective_id', string='Learning Objectives',
)
encoach_tag_ids = fields.Many2many(
'encoach.resource.tag', 'op_course_resource_tag_rel',
'course_id', 'tag_id', string='Tags',
)
difficulty_level = fields.Selection([
('beginner', 'Beginner'),
('intermediate', 'Intermediate'),
('advanced', 'Advanced'),
])
cefr_level = fields.Selection([
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
])
chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters')
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
objective_count = fields.Integer(compute='_compute_objective_count')
resource_count = fields.Integer(compute='_compute_resource_count')
@api.depends('chapter_ids')
def _compute_chapter_count(self):
for rec in self:
rec.chapter_count = len(rec.chapter_ids)
def _compute_objective_count(self):
for rec in self:
rec.objective_count = len(rec.learning_objective_ids)
def _compute_resource_count(self):
Mat = self.env['encoach.chapter.material'].sudo()
for rec in self:
mats = Mat.search([
('chapter_id.course_id', '=', rec.id),
('resource_id', '!=', False),
])
rec.resource_count = len(mats.mapped('resource_id'))

View File

@@ -0,0 +1,235 @@
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class EncoachCourseChapter(models.Model):
_name = 'encoach.course.chapter'
_description = 'Course Chapter'
_order = 'sequence, id'
name = fields.Char(required=True)
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
sequence = fields.Integer(default=10)
description = fields.Text()
start_date = fields.Date()
end_date = fields.Date()
unlock_mode = fields.Selection([
('auto_date', 'Automatic by Date'),
('manual', 'Manual'),
('prerequisite', 'Prerequisite'),
], default='manual')
is_unlocked = fields.Boolean(default=True)
topic_id = fields.Many2one('encoach.topic', ondelete='set null')
domain_id = fields.Many2one(
'encoach.domain', compute='_compute_domain_id', store=True,
ondelete='set null', string='Domain',
)
learning_objective_ids = fields.Many2many(
'encoach.learning.objective', 'encoach_chapter_obj_rel',
'chapter_id', 'objective_id', string='Learning Objectives',
)
material_ids = fields.One2many('encoach.chapter.material', 'chapter_id')
material_count = fields.Integer(compute='_compute_material_count', store=True)
@api.depends('topic_id', 'topic_id.domain_id')
def _compute_domain_id(self):
for rec in self:
rec.domain_id = rec.topic_id.domain_id.id if rec.topic_id and rec.topic_id.domain_id else False
@api.depends('material_ids')
def _compute_material_count(self):
for rec in self:
rec.material_count = len(rec.material_ids)
class EncoachChapterMaterial(models.Model):
_name = 'encoach.chapter.material'
_description = 'Chapter Material'
_order = 'sequence, id'
name = fields.Char(required=True)
chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade')
type = fields.Selection([
('pdf', 'PDF'),
('document', 'Document'),
('video', 'Video'),
('audio', 'Audio'),
('image', 'Image'),
('link', 'Link'),
('article', 'Article'),
], default='pdf')
file_data = fields.Binary(attachment=True)
file_name = fields.Char()
url = fields.Char()
description = fields.Text()
sequence = fields.Integer(default=10)
allow_download = fields.Boolean(default=True)
is_book = fields.Boolean(default=False)
book_chapters = fields.Text()
resource_id = fields.Many2one('encoach.resource', ondelete='set null',
help="Auto-created library resource mirror")
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._vector_index()
records._sync_library_resource()
return records
def write(self, vals):
res = super().write(vals)
if self.env.context.get('no_sync'):
return res
if any(f in vals for f in ('name', 'description', 'type')):
self._vector_index()
sync_fields = ('name', 'type', 'url', 'file_data')
if any(f in vals for f in sync_fields):
self._sync_library_resource()
return res
def unlink(self):
self._vector_delete()
resources = self.mapped('resource_id').filtered('exists')
res = super().unlink()
resources.unlink()
return res
def _sync_library_resource(self):
"""Create or update a matching encoach.resource for each material, propagating taxonomy."""
Res = self.env.get('encoach.resource')
if Res is None:
return
Res = self.env['encoach.resource'].sudo()
type_map = {
'pdf': 'pdf', 'document': 'document', 'video': 'video',
'audio': 'pdf', 'image': 'pdf', 'link': 'link', 'article': 'document',
}
for rec in self:
vals = {
'name': rec.name,
'type': type_map.get(rec.type, 'document'),
'url': rec.url or '',
'review_status': 'approved',
}
if rec.file_data:
vals['file'] = rec.file_data
chapter = rec.chapter_id
if chapter:
if chapter.topic_id:
vals['topic_ids'] = [(4, chapter.topic_id.id)]
if chapter.domain_id:
vals['domain_id'] = chapter.domain_id.id
course = chapter.course_id
if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id:
vals['subject_id'] = course.encoach_subject_id.id
if chapter.learning_objective_ids:
vals['learning_objective_ids'] = [(6, 0, chapter.learning_objective_ids.ids)]
if rec.resource_id and rec.resource_id.exists():
rec.resource_id.write(vals)
else:
new_res = Res.create(vals)
rec.with_context(no_sync=True).write({'resource_id': new_res.id})
def _vector_index(self):
"""Index material into the vector store for RAG retrieval with full taxonomy context."""
try:
svc_mod = self.env.get('encoach.embedding')
if svc_mod is None:
return
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
svc = EmbeddingService(self.env)
for rec in self:
parts = [rec.name or '']
if rec.description:
parts.append(str(rec.description)[:2000])
chapter = rec.chapter_id
if chapter:
if chapter.course_id:
parts.append(f"Course: {chapter.course_id.name}")
parts.append(f"Chapter: {chapter.name}")
if chapter.topic_id:
parts.append(f"Topic: {chapter.topic_id.name}")
if chapter.domain_id:
parts.append(f"Domain: {chapter.domain_id.name}")
course = chapter.course_id
if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id:
parts.append(f"Subject: {course.encoach_subject_id.name}")
for obj in chapter.learning_objective_ids:
parts.append(f"Objective: {obj.name}")
text = ' '.join(p for p in parts if p).strip()
if text:
metadata = {'type': rec.type or 'pdf'}
if chapter:
metadata['chapter_id'] = chapter.id
metadata['chapter_name'] = chapter.name or ''
if chapter.course_id:
metadata['course_id'] = chapter.course_id.id
metadata['course_name'] = chapter.course_id.name or ''
if chapter.topic_id:
metadata['topic_id'] = chapter.topic_id.id
metadata['topic_name'] = chapter.topic_id.name or ''
if chapter.domain_id:
metadata['domain_id'] = chapter.domain_id.id
metadata['domain_name'] = chapter.domain_id.name or ''
course = chapter.course_id
if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id:
metadata['subject_id'] = course.encoach_subject_id.id
metadata['subject_name'] = course.encoach_subject_id.name or ''
if chapter.learning_objective_ids:
metadata['objective_ids'] = chapter.learning_objective_ids.ids
metadata['objective_names'] = chapter.learning_objective_ids.mapped('name')
svc.upsert('material', rec.id, text, metadata)
self.env.cr.commit()
except Exception:
_logger.warning("Failed to vector-index material(s)", exc_info=True)
def _vector_delete(self):
"""Remove material embeddings from the vector store."""
try:
svc_mod = self.env.get('encoach.embedding')
if svc_mod is None:
return
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
svc = EmbeddingService(self.env)
for rec in self:
svc.delete('material', rec.id)
except Exception:
_logger.warning("Failed to delete material vector(s)", exc_info=True)
class EncoachChapterProgress(models.Model):
_name = 'encoach.chapter.progress'
_description = 'Chapter Progress'
_rec_name = 'chapter_id'
student_id = fields.Many2one('op.student', required=True, ondelete='cascade')
chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade')
status = fields.Selection([
('not_started', 'Not Started'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
], default='not_started')
started_at = fields.Datetime()
completed_at = fields.Datetime()
materials_completed = fields.Integer(default=0)
materials_total = fields.Integer(default=0)
class EncoachCourseCompletion(models.Model):
_name = 'encoach.course.completion'
_description = 'Course Completion'
_rec_name = 'course_id'
student_id = fields.Many2one('op.student', required=True, ondelete='cascade')
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
status = fields.Selection([
('in_progress', 'In Progress'),
('completed', 'Completed'),
], default='in_progress')
chapters_total = fields.Integer(default=0)
chapters_completed = fields.Integer(default=0)
progress_percent = fields.Integer(default=0)
completed_at = fields.Datetime()
post_test_available = fields.Boolean(default=False)

View File

@@ -0,0 +1,43 @@
from odoo import api, models, fields
FAQ_AUDIENCE = [
('all', 'All'),
('both', 'Both (Student + Entity)'),
('student', 'Students'),
('teacher', 'Teachers'),
('admin', 'Admins'),
('entity', 'Entity Users'),
]
class EncoachFaqCategory(models.Model):
_name = 'encoach.faq.category'
_description = 'FAQ Category'
_order = 'sequence, id'
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
audience = fields.Selection(FAQ_AUDIENCE, default='all')
is_published = fields.Boolean(default=True)
item_ids = fields.One2many('encoach.faq.item', 'category_id')
item_count = fields.Integer(compute='_compute_item_count', store=True)
@api.depends('item_ids')
def _compute_item_count(self):
for rec in self:
rec.item_count = len(rec.item_ids)
class EncoachFaqItem(models.Model):
_name = 'encoach.faq.item'
_description = 'FAQ Item'
_order = 'sequence, id'
category_id = fields.Many2one('encoach.faq.category', required=True, ondelete='cascade')
question = fields.Char(required=True)
answer = fields.Text(required=True)
sequence = fields.Integer(default=10)
audience = fields.Selection(FAQ_AUDIENCE, default='all')
is_published = fields.Boolean(default=True)
video_url = fields.Char(help='Optional walkthrough video URL.')

View File

@@ -0,0 +1,26 @@
from odoo import models, fields
class EncoachGradebook(models.Model):
_name = 'encoach.gradebook'
_description = 'Gradebook'
student_id = fields.Many2one('op.student', required=True, ondelete='cascade')
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
academic_year_id = fields.Many2one('op.academic.year', ondelete='set null')
line_ids = fields.One2many('encoach.gradebook.line', 'gradebook_id')
class EncoachGradebookLine(models.Model):
_name = 'encoach.gradebook.line'
_description = 'Gradebook Line'
gradebook_id = fields.Many2one('encoach.gradebook', required=True, ondelete='cascade')
assignment_name = fields.Char()
marks = fields.Float()
percentage = fields.Float()
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('graded', 'Graded'),
], default='draft')

View File

@@ -0,0 +1,24 @@
from odoo import models, fields
class EncoachLesson(models.Model):
_name = 'encoach.lesson'
_description = 'Lesson'
_order = 'start_datetime desc, id desc'
name = fields.Char(string='Name')
lesson_topic = fields.Char(string='Topic')
course_id = fields.Many2one('op.course', string='Course', ondelete='cascade')
batch_id = fields.Many2one('op.batch', string='Batch', ondelete='set null')
subject_id = fields.Many2one('op.subject', string='Subject', ondelete='set null')
session_id = fields.Many2one('op.session', string='Session', ondelete='set null')
faculty_id = fields.Many2one('op.faculty', string='Faculty', ondelete='set null')
start_datetime = fields.Datetime(string='Start')
end_datetime = fields.Datetime(string='End')
content = fields.Text(string='Content')
status = fields.Selection([
('draft', 'Draft'),
('scheduled', 'Scheduled'),
('done', 'Done'),
('cancelled', 'Cancelled'),
], default='draft', string='Status')

View File

@@ -0,0 +1,71 @@
from odoo import models, fields
class EncoachNotification(models.Model):
_name = 'encoach.notification'
_description = 'Notification'
_order = 'create_date desc'
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
title = fields.Char(required=True)
message = fields.Text()
type = fields.Selection([
('info', 'Info'),
('warning', 'Warning'),
('success', 'Success'),
('error', 'Error'),
], default='info')
is_read = fields.Boolean(default=False)
read_at = fields.Datetime()
reference_model = fields.Char()
reference_id = fields.Integer()
class EncoachNotificationRule(models.Model):
_name = 'encoach.notification.rule'
_description = 'Notification Rule'
name = fields.Char(required=True)
event_type = fields.Char(required=True, help='e.g. deadline, chapter_unlock, '
'result_release, assignment, exam')
template = fields.Text()
is_active = fields.Boolean(default=True)
recipients = fields.Selection([
('all', 'All Users'),
('students', 'Students Only'),
('teachers', 'Teachers Only'),
('admins', 'Admins Only'),
], default='all')
channels = fields.Char(help='Legacy: comma-separated email,push,in_app. '
'New code should use `channel` instead.')
# New fields aligned with the admin Configuration UI (NotificationRules page)
days_before = fields.Integer(default=1,
help='Lead time in days before the event fires.')
frequency = fields.Selection([
('once', 'Once'),
('daily', 'Daily'),
('custom', 'Custom schedule'),
], default='once')
channel = fields.Selection([
('in_app', 'In-App'),
('email', 'Email'),
('both', 'Both'),
], default='in_app')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
class EncoachNotificationPreference(models.Model):
_name = 'encoach.notification.preference'
_description = 'Notification Preference'
_rec_name = 'user_id'
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
email_enabled = fields.Boolean(default=True)
push_enabled = fields.Boolean(default=True)
in_app_enabled = fields.Boolean(default=True)
digest_frequency = fields.Selection([
('realtime', 'Real-time'),
('daily', 'Daily'),
('weekly', 'Weekly'),
], default='realtime')

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