Merge pull request 'v4' (#4) from v4 into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
30
.cursor/rules/encoach-project-context.mdc
Normal file
30
.cursor/rules/encoach-project-context.mdc
Normal 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
109
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------
|
||||
# Frontend: type-check, lint, build, and run Playwright smoke tests.
|
||||
# --------------------------------------------------------------------
|
||||
frontend:
|
||||
name: Frontend — lint + build + e2e
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit -p tsconfig.app.json
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright smoke tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report
|
||||
retention-days: 7
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Backend: run the Odoo HttpCase smoke suite via the official image.
|
||||
#
|
||||
# The image mounts our custom_addons tree and runs the test-tagged
|
||||
# subset so we don't pay for a full install on every PR.
|
||||
# --------------------------------------------------------------------
|
||||
backend:
|
||||
name: Backend — Odoo HttpCase
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: odoo
|
||||
POSTGRES_PASSWORD: odoo
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U odoo"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Odoo HttpCase tests in a throwaway container
|
||||
run: |
|
||||
docker run --rm \
|
||||
--network host \
|
||||
-v "$GITHUB_WORKSPACE/backend/custom_addons:/mnt/extra-addons" \
|
||||
-e HOST=localhost \
|
||||
-e USER=odoo \
|
||||
-e PASSWORD=odoo \
|
||||
odoo:19 \
|
||||
--stop-after-init \
|
||||
--test-enable \
|
||||
--test-tags "encoach_api" \
|
||||
--log-level=warn \
|
||||
--without-demo=all \
|
||||
--init=encoach_api,encoach_core \
|
||||
-d encoach_ci || {
|
||||
echo "::warning::Odoo HttpCase suite failed — see logs above."
|
||||
exit 1
|
||||
}
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGUSER: odoo
|
||||
PGPASSWORD: odoo
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -20,6 +20,12 @@ miniconda3/
|
||||
|
||||
# PostgreSQL data
|
||||
pgdata/
|
||||
pgdata_bak_*/
|
||||
|
||||
# Frontend build cache
|
||||
frontend/.vite/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
@@ -3,8 +3,11 @@ FROM odoo:19.0
|
||||
USER root
|
||||
|
||||
# Install all Python dependencies required by EnCoach custom addons.
|
||||
# --ignore-installed: skip uninstalling system packages that lack RECORD files (e.g. Debian's typing_extensions).
|
||||
COPY new_project/requirements.txt /tmp/requirements.txt
|
||||
# --ignore-installed: skip uninstalling system packages that lack RECORD files
|
||||
# (e.g. Debian's typing_extensions).
|
||||
# Canonical source: repo-root requirements.txt (the legacy copy under
|
||||
# new_project/ is no longer authoritative — see new_project/DEPRECATED.md).
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install -r /tmp/requirements.txt --break-system-packages --no-warn-script-location --ignore-installed
|
||||
|
||||
USER odoo
|
||||
|
||||
267
README.md
267
README.md
@@ -1,123 +1,190 @@
|
||||
# EnCoach Backend v2 — Odoo 19 Adaptive Learning Platform
|
||||
# EnCoach — Adaptive AI Learning Platform
|
||||
|
||||
The Odoo 19 backend for the **EnCoach** adaptive learning and institutional LMS platform. Provides ~377 REST API endpoints serving a React 18 SPA frontend.
|
||||
EnCoach is a smart learning environment for individual and collaborative learning,
|
||||
fully integrated with AI and equipped with intelligent, professional-grade
|
||||
exercises, assessments, and e-exams.
|
||||
|
||||
## System Overview
|
||||
This repository hosts the full stack:
|
||||
|
||||
| Component | Detail |
|
||||
|-----------|--------|
|
||||
| **Framework** | Odoo 19 Community Edition |
|
||||
| **Language** | Python 3.12+ |
|
||||
| **Database** | PostgreSQL 16 |
|
||||
| **LMS Foundation** | OpenEduCat Community (14 modules, ported to v19) |
|
||||
| **Custom Modules** | 27 `encoach_*` modules |
|
||||
| **AI/ML** | OpenAI GPT-4o, Whisper (local), AWS Polly, ELAI, GPTZero, FAISS |
|
||||
| **Authentication** | JWT (PyJWT) |
|
||||
| **Deployment** | Docker Compose |
|
||||
- **Backend** — Odoo 19 + ~30 `encoach_*` modules (Python 3.12, PostgreSQL 16)
|
||||
- **Frontend** — React 18 + Vite + TypeScript single-page app
|
||||
- **AI layer** — OpenAI + pgvector RAG, quality-gate validation, IELTS validator
|
||||
- **Ops** — Docker Compose, JWT auth with refresh tokens, Prometheus-compatible
|
||||
metrics, dynamic OpenAPI 3.0 spec
|
||||
|
||||
## Module Architecture
|
||||
> **Canonical trees:** `backend/` (all server code) and `frontend/` (all client
|
||||
> code). The legacy `new_project/` directory is deprecated — see
|
||||
> [`new_project/DEPRECATED.md`](new_project/DEPRECATED.md).
|
||||
|
||||
```
|
||||
new_project/
|
||||
custom_addons/ # 27 custom EnCoach modules
|
||||
encoach_api/ # REST API controllers (~120 routes)
|
||||
encoach_lms_api/ # OpenEduCat bridge API (~209 routes)
|
||||
encoach_adaptive_api/ # Adaptive learning API (~41 routes)
|
||||
encoach_resources/ # Resource library API (~7 routes)
|
||||
encoach_core/ # Users, entities, roles, permissions
|
||||
encoach_taxonomy/ # Subject > Domain > Topic taxonomy
|
||||
encoach_adaptive/ # Proficiency, learning plans, mastery
|
||||
encoach_adaptive_ai/ # AI for diagnostics, coaching, content
|
||||
encoach_exam/ # AI-powered exam engine
|
||||
encoach_ai/ # OpenAI service wrapper
|
||||
encoach_ai_generation/ # AI exam content generation
|
||||
encoach_ai_grading/ # AI grading (writing, speaking, math, IT)
|
||||
encoach_ai_media/ # TTS (Polly), STT (Whisper), ELAI avatars
|
||||
encoach_courseware/ # Chapters, materials, AI workbench
|
||||
encoach_communication/ # Discussion boards, announcements, messaging
|
||||
encoach_notification/ # Notification engine, rules, preferences
|
||||
encoach_faq/ # FAQ categories and items
|
||||
encoach_approval/ # Approval workflows, stages
|
||||
encoach_assignment/ # Exam-wrapper assignments
|
||||
encoach_classroom/ # Virtual classroom groups
|
||||
encoach_stats/ # Session tracking, statistics
|
||||
encoach_training/ # Training tips, walkthroughs, FAISS
|
||||
encoach_subscription/ # Packages, Stripe/PayPal/Paymob
|
||||
encoach_registration/ # User registration, batch import
|
||||
encoach_ticket/ # Support tickets
|
||||
encoach_sis/ # UTAS SIS integration
|
||||
encoach_branding/ # Whitelabeling
|
||||
openeducat_erp-19.0/ # 14 OpenEduCat community modules
|
||||
openeducat_core/ # Courses, batches, students, faculty
|
||||
openeducat_timetable/ # Timetable sessions
|
||||
openeducat_attendance/ # Attendance management
|
||||
openeducat_exam/ # Institutional exams, marksheets
|
||||
openeducat_assignment/ # Course assignments, submissions
|
||||
openeducat_admission/ # Admission registers, applications
|
||||
openeducat_classroom/ # Physical classrooms
|
||||
openeducat_facility/ # Facilities and assets
|
||||
openeducat_fees/ # Fee plans, student fees
|
||||
openeducat_library/ # Library media, movements, cards
|
||||
openeducat_activity/ # Student activities
|
||||
openeducat_parent/ # Parent-student relationships
|
||||
openeducat_erp/ # ERP connector
|
||||
theme_web_openeducat/ # Web theme
|
||||
```
|
||||
---
|
||||
|
||||
## Staging
|
||||
## 1. Quickstart
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| **Odoo Backend** | `http://5.189.151.117:8069` |
|
||||
| **React Frontend** | `http://5.189.151.117:3000` |
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
This repo is connected to the staging server via a Git post-receive hook.
|
||||
**All deployment is automatic after code review approval.**
|
||||
|
||||
1. Never push directly to `main` -- branch protection will block it.
|
||||
2. Push your changes to a feature branch (e.g. `feature/full-backend-v1`).
|
||||
3. Open a Pull Request on Gitea targeting `main`.
|
||||
4. Request review from **devops (Talal)**.
|
||||
5. Once approved and merged, the staging server rebuilds and redeploys automatically.
|
||||
|
||||
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`.
|
||||
|
||||
## Local Development
|
||||
|
||||
### Option A: Docker (easiest)
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# Odoo: http://localhost:8069
|
||||
# Frontend: cd frontend && npm install && npm run dev (http://localhost:8080)
|
||||
```
|
||||
|
||||
Open `http://localhost:8069`. First run: create a new database.
|
||||
Create a new Odoo database on first visit, then install the `encoach_api`
|
||||
module to pull in every `encoach_*` dependency.
|
||||
|
||||
Stop: `docker compose down`
|
||||
### Source
|
||||
|
||||
### Option B: Source (venv + PostgreSQL)
|
||||
|
||||
**Prerequisites:** macOS, Homebrew, Python 3.11+, PostgreSQL 15+
|
||||
Prerequisites: Python 3.12, PostgreSQL 16, Node 20, npm 10.
|
||||
|
||||
```bash
|
||||
brew install postgresql@15 wkhtmltopdf
|
||||
brew services start postgresql@15
|
||||
createuser -s $(whoami)
|
||||
# Backend
|
||||
./setup.sh # creates venv, installs requirements.txt
|
||||
./run.sh # starts Odoo on :8069
|
||||
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
./run.sh
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # starts Vite on :8080, proxies /api → :8069
|
||||
```
|
||||
|
||||
Open `http://localhost:8069`.
|
||||
See [`MANUAL-RUN.md`](MANUAL-RUN.md) for a step-by-step walkthrough and
|
||||
[`CONNECT-POSTGRES.md`](CONNECT-POSTGRES.md) for database wiring.
|
||||
|
||||
See [MANUAL-RUN.md](MANUAL-RUN.md) for step-by-step commands.
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
## 2. Repository layout
|
||||
|
||||
```
|
||||
odoo19/
|
||||
├── backend/
|
||||
│ └── custom_addons/
|
||||
│ ├── encoach_api/ REST base + JWT + OpenAPI + metrics
|
||||
│ ├── encoach_core/ Users, entities, roles, permissions
|
||||
│ ├── encoach_taxonomy/ Subject → Domain → Topic
|
||||
│ ├── encoach_ai/ OpenAI wrapper, cefr_mapper, validator
|
||||
│ ├── encoach_ai_course/ AI course/exam generation pipelines
|
||||
│ ├── encoach_ai_grading/ AI grading (writing/speaking/math/IT)
|
||||
│ ├── encoach_ai_media/ TTS (Polly), STT (Whisper), ELAI
|
||||
│ ├── encoach_vector/ pgvector store + RAG embeddings
|
||||
│ ├── encoach_exam_template/ Canonical exam + student attempt models
|
||||
│ ├── encoach_scoring/ Score computation, CEFR mapping
|
||||
│ ├── encoach_quality_gate/ Automated content-quality checks
|
||||
│ ├── encoach_ielts_validation/ IELTS-specific validators
|
||||
│ ├── encoach_adaptive/ Adaptive engine, style matcher
|
||||
│ ├── encoach_lms_api/ OpenEduCat bridge + LMS endpoints
|
||||
│ ├── encoach_branding/ White-label config per entity
|
||||
│ └── … (other encoach_* modules)
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ Route pages (React.lazy code-split)
|
||||
│ │ ├── components/ Shared UI (shadcn/ui + custom)
|
||||
│ │ ├── services/ Thin API wrappers over fetch
|
||||
│ │ ├── hooks/queries/ React Query hooks + keys
|
||||
│ │ ├── lib/api-client.ts Fetch + auto JWT refresh
|
||||
│ │ └── types/ Shared DTO types
|
||||
│ └── vite.config.ts manualChunks: react, query, charts, radix…
|
||||
├── docs/
|
||||
│ ├── PROJECT_SUMMARY.md Release notes & architecture history
|
||||
│ ├── adr/ Architecture Decision Records
|
||||
│ └── ENCOACH_*.md SRS, workflows, user stories
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── requirements.txt Python deps (pgvector, textstat, etc.)
|
||||
└── README.md You are here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture at a glance
|
||||
|
||||
```
|
||||
┌────────────────────┐ HTTPS/JWT ┌────────────────────┐
|
||||
│ React SPA (Vite) │ ───────────────► │ Odoo 19 + FastAPI-style │
|
||||
│ - React Query │ ◄─────────────── │ controllers (encoach_*) │
|
||||
│ - next-themes │ X-Request-Id └──────┬─────────────┘
|
||||
│ - shadcn/ui │ │
|
||||
└────────────────────┘ ▼
|
||||
┌──────────────┐
|
||||
│ PostgreSQL │
|
||||
│ + pgvector │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ OpenAI, │
|
||||
│ Whisper, │
|
||||
│ Polly… │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
Every request carries an `X-Request-Id` and emits structured JSON logs.
|
||||
Prometheus-compatible counters are exposed at `/api/metrics`, and the live
|
||||
OpenAPI 3.0 spec is at `/api/openapi.json`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key conventions
|
||||
|
||||
- **Canonical response envelope** — list endpoints return
|
||||
`{ items: T[], total, page, size, data: T[] }` (see `encoach_api.controllers.base.paginated_envelope`).
|
||||
- **CEFR mapping** — only `encoach_ai.services.cefr_mapper` is canonical. Do
|
||||
not reintroduce local `band_to_cefr` copies.
|
||||
- **JWT tokens** — short-lived access tokens (1h) + revocable refresh tokens
|
||||
(7d). Only `access` tokens are accepted as Bearer credentials; refresh tokens
|
||||
must go through `/api/auth/refresh`. See [`docs/adr/0002-jwt-refresh-token-flow.md`](docs/adr/0002-jwt-refresh-token-flow.md).
|
||||
- **RAG metadata** — vector embeddings carry `course_id`, `subject_id`,
|
||||
`entity_id`, `taxonomy`, `content_hash`. Chunking kicks in above 2 000 chars.
|
||||
- **Frontend pagination** — `PaginatedResponse<T>` exposes both `items` and
|
||||
`data`. Read from `items` in new code.
|
||||
- **Frontend theming** — tokens live in `frontend/src/index.css` (`:root` and
|
||||
`.dark`). Always use `hsl(var(--token))` instead of raw hex.
|
||||
|
||||
---
|
||||
|
||||
## 5. Health, observability, docs
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/health` | Liveness (always 200 when server is up) |
|
||||
| `GET /api/health/ready` | Readiness (DB + required config) |
|
||||
| `GET /api/openapi.json` | Dynamic OpenAPI 3.0 spec generated from `@http.route` |
|
||||
| `GET /api/metrics` | Prometheus-format counters per route |
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment
|
||||
|
||||
Staging and production both use Docker Compose. The staging server rebuilds
|
||||
automatically from `main`; never force-push. See
|
||||
[`INSTALL-ODOO-SUMMARY.md`](INSTALL-ODOO-SUMMARY.md) for bootstrap notes.
|
||||
|
||||
| Service | Staging URL |
|
||||
|---|---|
|
||||
| Odoo backend | `http://5.189.151.117:8069` |
|
||||
| React frontend | `http://5.189.151.117:3000` |
|
||||
|
||||
The `.env` file is **never** committed. On staging it lives at
|
||||
`/opt/encoach/backend-v2/.env`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Further reading
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| `ENCOACH_ODOO19_BACKEND_SRS.md` | Backend SRS v3.0 (definitive) |
|
||||
| `ENCOACH_UNIFIED_SRS.md` | Unified frontend + backend SRS v2.0 |
|
||||
| `ENCOACH_PRODUCT_DESCRIPTION.md` | Non-technical product description |
|
||||
|---|---|
|
||||
| [`docs/PROJECT_SUMMARY.md`](docs/PROJECT_SUMMARY.md) | Release notes + architecture history |
|
||||
| [`docs/adr/`](docs/adr/) | Architecture Decision Records (why we built it this way) |
|
||||
| [`docs/ENCOACH_UNIFIED_SRS.md`](docs/ENCOACH_UNIFIED_SRS.md) | Unified frontend + backend SRS |
|
||||
| [`docs/ENCOACH_ODOO19_BACKEND_SRS.md`](docs/ENCOACH_ODOO19_BACKEND_SRS.md) | Backend SRS v3.0 |
|
||||
| [`docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md`](docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md) | Backend workflows |
|
||||
| [`docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md`](docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md) | Frontend workflows |
|
||||
|
||||
---
|
||||
|
||||
## 8. Contributing
|
||||
|
||||
1. Branch from `main` — never push direct. Branch protection enforces it.
|
||||
2. Run `npx tsc --noEmit -p tsconfig.app.json` (frontend) and the module smoke
|
||||
tests before opening a PR.
|
||||
3. Every architectural decision should be captured as an ADR under
|
||||
[`docs/adr/`](docs/adr/). Copy `0000-template.md` to start one.
|
||||
4. Open the PR against `main` and request review from devops (Talal).
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Endpoints for submitting & aggregating AI feedback."""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
_json_response,
|
||||
_paginate,
|
||||
jwt_required,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _feedback_to_dict(row):
|
||||
return {
|
||||
"id": row.id,
|
||||
"subject_type": row.subject_type,
|
||||
"subject_id": row.subject_id,
|
||||
"subject_key": row.subject_key,
|
||||
"rating": row.rating,
|
||||
"comment": row.comment or "",
|
||||
"tags": (row.tags or "").split(",") if row.tags else [],
|
||||
"prompt_key": row.prompt_key or None,
|
||||
"prompt_version": row.prompt_version or None,
|
||||
"ai_log_id": row.ai_log_id.id if row.ai_log_id else None,
|
||||
"user_id": row.user_id.id,
|
||||
"user_name": row.user_id.name,
|
||||
"entity_id": row.entity_id.id if row.entity_id else None,
|
||||
"course_id": row.course_id.id if row.course_id else None,
|
||||
"status": row.status,
|
||||
"create_date": (
|
||||
fields.Datetime.to_string(row.create_date) if row.create_date else None
|
||||
),
|
||||
"resolved_at": (
|
||||
fields.Datetime.to_string(row.resolved_at) if row.resolved_at else None
|
||||
),
|
||||
"resolution_notes": row.resolution_notes or "",
|
||||
}
|
||||
|
||||
|
||||
class EncoachAIFeedbackController(http.Controller):
|
||||
"""Feedback write + admin triage endpoints."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/feedback — student submits/updates feedback
|
||||
#
|
||||
# Upsert semantics: we enforce uniqueness per (user, subject_type,
|
||||
# subject_id) at the DB layer, so the client doesn't need to know whether
|
||||
# the row already exists.
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/api/ai/feedback", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def submit(self, **kw):
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
subject_type = body.get("subject_type")
|
||||
subject_id = body.get("subject_id")
|
||||
rating = body.get("rating")
|
||||
if subject_type not in {
|
||||
"question", "coach", "explanation", "translation", "narrative", "other",
|
||||
}:
|
||||
return _error_response("Invalid subject_type", 400)
|
||||
if not isinstance(subject_id, int) or subject_id <= 0:
|
||||
return _error_response("subject_id must be a positive integer", 400)
|
||||
if rating not in {"up", "down"}:
|
||||
return _error_response("rating must be 'up' or 'down'", 400)
|
||||
comment = body.get("comment") or ""
|
||||
if rating == "down" and not (comment or "").strip():
|
||||
# We keep this a soft constraint so the UI can decide how
|
||||
# strict to be; returning an error here gives clients a clear
|
||||
# path to prompt the user when needed.
|
||||
return _error_response(
|
||||
"A short comment is required when rating is 'down'.", 400,
|
||||
)
|
||||
|
||||
vals = {
|
||||
"subject_type": subject_type,
|
||||
"subject_id": subject_id,
|
||||
"rating": rating,
|
||||
"comment": comment,
|
||||
"tags": (
|
||||
",".join(body.get("tags") or [])
|
||||
if isinstance(body.get("tags"), list)
|
||||
else (body.get("tags") or "")
|
||||
),
|
||||
"prompt_key": body.get("prompt_key") or False,
|
||||
"prompt_version": body.get("prompt_version") or 0,
|
||||
"ai_log_id": body.get("ai_log_id") or False,
|
||||
"entity_id": body.get("entity_id") or False,
|
||||
"course_id": body.get("course_id") or False,
|
||||
"user_id": request.env.user.id,
|
||||
}
|
||||
|
||||
Feedback = request.env["encoach.ai.feedback"].sudo()
|
||||
existing = Feedback.search([
|
||||
("user_id", "=", request.env.user.id),
|
||||
("subject_type", "=", subject_type),
|
||||
("subject_id", "=", subject_id),
|
||||
], limit=1)
|
||||
with request.env.cr.savepoint():
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
row = existing
|
||||
else:
|
||||
row = Feedback.create(vals)
|
||||
return _json_response(_feedback_to_dict(row), 200 if existing else 201)
|
||||
except Exception as e:
|
||||
_logger.exception("ai feedback submit failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/feedback/summary?subject_type=question&subject_id=42
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/feedback/summary", type="http", auth="none", methods=["GET"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def summary(self, subject_type=None, subject_id=None, **kw):
|
||||
try:
|
||||
if not subject_type or not subject_id:
|
||||
return _error_response("subject_type and subject_id are required", 400)
|
||||
Feedback = request.env["encoach.ai.feedback"].sudo()
|
||||
domain = [
|
||||
("subject_type", "=", subject_type),
|
||||
("subject_id", "=", int(subject_id)),
|
||||
]
|
||||
rows = Feedback.search(domain)
|
||||
up = sum(1 for r in rows if r.rating == "up")
|
||||
down = sum(1 for r in rows if r.rating == "down")
|
||||
my_row = Feedback.search([
|
||||
*domain,
|
||||
("user_id", "=", request.env.user.id),
|
||||
], limit=1)
|
||||
return _json_response({
|
||||
"subject_type": subject_type,
|
||||
"subject_id": int(subject_id),
|
||||
"up": up,
|
||||
"down": down,
|
||||
"total": up + down,
|
||||
"my_rating": my_row.rating if my_row else None,
|
||||
"my_comment": my_row.comment or "" if my_row else "",
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("ai feedback summary failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/feedback — admin triage list
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/api/ai/feedback", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def list_feedback(self, **kw):
|
||||
try:
|
||||
if not request.env.user.has_group("base.group_system"):
|
||||
return _error_response("Admin privileges required", 403)
|
||||
Feedback = request.env["encoach.ai.feedback"].sudo()
|
||||
domain = []
|
||||
if kw.get("status"):
|
||||
domain.append(("status", "=", kw["status"]))
|
||||
if kw.get("rating"):
|
||||
domain.append(("rating", "=", kw["rating"]))
|
||||
if kw.get("subject_type"):
|
||||
domain.append(("subject_type", "=", kw["subject_type"]))
|
||||
if kw.get("prompt_key"):
|
||||
domain.append(("prompt_key", "=", kw["prompt_key"]))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Feedback.search_count(domain)
|
||||
rows = Feedback.search(domain, offset=offset, limit=limit, order="create_date desc")
|
||||
items = [_feedback_to_dict(r) for r in rows]
|
||||
return _json_response({
|
||||
"items": items,
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": limit,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("ai feedback list failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/feedback/<id>/resolve — admin triage
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
'/api/ai/feedback/<int:feedback_id>/resolve',
|
||||
type="http", auth="none", methods=["POST"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def resolve(self, feedback_id, **kw):
|
||||
try:
|
||||
if not request.env.user.has_group("base.group_system"):
|
||||
return _error_response("Admin privileges required", 403)
|
||||
body = _get_json_body() or {}
|
||||
status = body.get("status")
|
||||
if status not in {"acknowledged", "fixed", "dismissed"}:
|
||||
return _error_response(
|
||||
"status must be one of acknowledged|fixed|dismissed", 400,
|
||||
)
|
||||
row = request.env["encoach.ai.feedback"].sudo().browse(feedback_id)
|
||||
if not row.exists():
|
||||
return _error_response("Feedback not found", 404)
|
||||
with request.env.cr.savepoint():
|
||||
row.write({
|
||||
"status": status,
|
||||
"resolved_by_id": request.env.user.id,
|
||||
"resolved_at": fields.Datetime.now(),
|
||||
"resolution_notes": body.get("notes") or "",
|
||||
})
|
||||
return _json_response(_feedback_to_dict(row))
|
||||
except Exception as e:
|
||||
_logger.exception("ai feedback resolve failed")
|
||||
return _error_response(str(e), 500)
|
||||
@@ -5,6 +5,10 @@ import json
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response as _base_json_response, _get_json_body,
|
||||
)
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,7 +56,8 @@ class MediaController(http.Controller):
|
||||
)
|
||||
|
||||
# ── POST /api/exam/listening/media — generate listening audio ──
|
||||
@http.route("/api/exam/listening/media", type="http", auth="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:
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Admin endpoints for the versioned AI prompt editor."""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
_json_response,
|
||||
_paginate,
|
||||
jwt_required,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _prompt_to_dict(prompt, *, include_content=True):
|
||||
data = {
|
||||
"id": prompt.id,
|
||||
"key": prompt.key,
|
||||
"version": prompt.version,
|
||||
"title": prompt.title or "",
|
||||
"description": prompt.description or "",
|
||||
"is_active": bool(prompt.is_active),
|
||||
"variables": (prompt.variables or "").split(",") if prompt.variables else [],
|
||||
"author_id": prompt.author_id.id if prompt.author_id else None,
|
||||
"author_name": prompt.author_id.name if prompt.author_id else "",
|
||||
"activated_at": (
|
||||
fields.Datetime.to_string(prompt.activated_at) if prompt.activated_at else None
|
||||
),
|
||||
"create_date": (
|
||||
fields.Datetime.to_string(prompt.create_date) if prompt.create_date else None
|
||||
),
|
||||
}
|
||||
if include_content:
|
||||
data["content"] = prompt.content or ""
|
||||
return data
|
||||
|
||||
|
||||
class EncoachAIPromptController(http.Controller):
|
||||
"""Versioned prompt CRUD.
|
||||
|
||||
Auth: every endpoint requires a valid JWT. The write endpoints additionally
|
||||
check for admin rights via ``res.users.has_group('base.group_system')`` so
|
||||
non-admins can read prompts but not mutate them.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/prompts — list keys (latest version per key)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/api/ai/prompts", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def list_keys(self, **kw):
|
||||
try:
|
||||
Prompt = request.env["encoach.ai.prompt"].sudo()
|
||||
# One row per key = the latest version. We fetch candidates then
|
||||
# deduplicate in Python because Odoo's ORM doesn't expose DISTINCT
|
||||
# ON ergonomically.
|
||||
search = (kw.get("search") or "").strip()
|
||||
domain = [("key", "ilike", search)] if search else []
|
||||
# Sort by key then descending version so the first occurrence per
|
||||
# key is the latest.
|
||||
all_prompts = Prompt.search(domain, order="key asc, version desc")
|
||||
seen = set()
|
||||
latest = []
|
||||
for p in all_prompts:
|
||||
if p.key in seen:
|
||||
continue
|
||||
seen.add(p.key)
|
||||
latest.append(p)
|
||||
offset, per_page, page = _paginate(kw)
|
||||
total = len(latest)
|
||||
window = latest[offset : offset + per_page]
|
||||
items = [_prompt_to_dict(p, include_content=False) for p in window]
|
||||
# Attach version-count for the UI to show "v5 (of 7)" hints.
|
||||
counts = {}
|
||||
for p in all_prompts:
|
||||
counts[p.key] = counts.get(p.key, 0) + 1
|
||||
for entry in items:
|
||||
entry["total_versions"] = counts.get(entry["key"], 1)
|
||||
return _json_response({
|
||||
"items": items,
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": per_page,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("list ai prompts failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/prompts/<key>/versions
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
'/api/ai/prompts/<string:key>/versions',
|
||||
type="http", auth="none", methods=["GET"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def list_versions(self, key, **kw):
|
||||
try:
|
||||
Prompt = request.env["encoach.ai.prompt"].sudo()
|
||||
prompts = Prompt.search([("key", "=", key)], order="version desc")
|
||||
if not prompts:
|
||||
return _error_response("Prompt key not found", 404)
|
||||
items = [_prompt_to_dict(p, include_content=False) for p in prompts]
|
||||
return _json_response({
|
||||
"key": key,
|
||||
"items": items,
|
||||
"data": items,
|
||||
"total": len(items),
|
||||
"page": 1,
|
||||
"size": len(items),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("list prompt versions failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/prompts/<int:prompt_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
'/api/ai/prompts/<int:prompt_id>',
|
||||
type="http", auth="none", methods=["GET"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def get_version(self, prompt_id, **kw):
|
||||
try:
|
||||
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
|
||||
if not prompt.exists():
|
||||
return _error_response("Prompt not found", 404)
|
||||
return _json_response(_prompt_to_dict(prompt))
|
||||
except Exception as e:
|
||||
_logger.exception("get prompt failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/prompts — create a new version of a key (or a new key)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/api/ai/prompts", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def create_version(self, **kw):
|
||||
try:
|
||||
if not request.env.user.has_group("base.group_system"):
|
||||
return _error_response("Admin privileges required", 403)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
key = (body.get("key") or "").strip()
|
||||
content = body.get("content") or ""
|
||||
title = (body.get("title") or "").strip()
|
||||
if not key or not content.strip() or not title:
|
||||
return _error_response(
|
||||
"key, title, and content are required", 400,
|
||||
)
|
||||
|
||||
vals = {
|
||||
"key": key,
|
||||
"title": title,
|
||||
"description": body.get("description") or "",
|
||||
"content": content,
|
||||
"is_active": bool(body.get("activate", False)),
|
||||
}
|
||||
with request.env.cr.savepoint():
|
||||
prompt = request.env["encoach.ai.prompt"].sudo().create(vals)
|
||||
return _json_response(_prompt_to_dict(prompt), 201)
|
||||
except Exception as e:
|
||||
_logger.exception("create prompt version failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/prompts/<id>/activate
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
'/api/ai/prompts/<int:prompt_id>/activate',
|
||||
type="http", auth="none", methods=["POST"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def activate(self, prompt_id, **kw):
|
||||
try:
|
||||
if not request.env.user.has_group("base.group_system"):
|
||||
return _error_response("Admin privileges required", 403)
|
||||
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
|
||||
if not prompt.exists():
|
||||
return _error_response("Prompt not found", 404)
|
||||
with request.env.cr.savepoint():
|
||||
prompt.write({"is_active": True})
|
||||
return _json_response(_prompt_to_dict(prompt))
|
||||
except Exception as e:
|
||||
_logger.exception("activate prompt failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/prompts/<id>/render — dry-run render with sample variables
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
'/api/ai/prompts/<int:prompt_id>/render',
|
||||
type="http", auth="none", methods=["POST"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def render_preview(self, prompt_id, **kw):
|
||||
try:
|
||||
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
|
||||
if not prompt.exists():
|
||||
return _error_response("Prompt not found", 404)
|
||||
body = _get_json_body() or {}
|
||||
variables = body.get("variables") or {}
|
||||
if not isinstance(variables, dict):
|
||||
return _error_response("variables must be a JSON object", 400)
|
||||
rendered = prompt.render(variables)
|
||||
return _json_response({
|
||||
"rendered": rendered,
|
||||
"key": prompt.key,
|
||||
"version": prompt.version,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("render prompt failed")
|
||||
return _error_response(str(e), 400)
|
||||
@@ -1,2 +1,5 @@
|
||||
from . import ai_settings
|
||||
from . import ai_log
|
||||
from . import ai_prompt
|
||||
from . import ai_feedback
|
||||
from . import constants
|
||||
|
||||
134
backend/custom_addons/encoach_ai/models/ai_feedback.py
Normal file
134
backend/custom_addons/encoach_ai/models/ai_feedback.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Student feedback on AI-generated content (thumbs up/down).
|
||||
|
||||
This closes the AI quality loop started by the human-review workflow (P3.3)
|
||||
and the versioned prompts (P3.4):
|
||||
|
||||
* Every AI-generated artefact (a question, a coach reply, an explanation, a
|
||||
translation, ...) can have any number of feedback rows attached to it.
|
||||
* Feedback is always coupled to a **subject type + subject id** so it can
|
||||
point at anything (``encoach.question``, ``encoach.ai.log``, ...) without
|
||||
forcing a hard FK per model.
|
||||
* When the student flags feedback as negative, we also store their free-text
|
||||
note so product can triage.
|
||||
|
||||
Downstream consumers (prompt library, RAG indexers, report dashboards) can
|
||||
read ``encoach.ai.feedback`` to compute win rates per prompt key, per model,
|
||||
per entity, etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EncoachAIFeedback(models.Model):
|
||||
_name = "encoach.ai.feedback"
|
||||
_description = "Student feedback on AI-generated content"
|
||||
_order = "create_date desc"
|
||||
_rec_name = "subject_key"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# What was rated
|
||||
# ------------------------------------------------------------------
|
||||
subject_type = fields.Selection(
|
||||
[
|
||||
("question", "Exam question"),
|
||||
("coach", "AI Coach reply"),
|
||||
("explanation", "Content explanation"),
|
||||
("translation", "Translation"),
|
||||
("narrative", "Report narrative"),
|
||||
("other", "Other AI output"),
|
||||
],
|
||||
required=True, index=True,
|
||||
)
|
||||
subject_id = fields.Integer(
|
||||
required=True, index=True,
|
||||
help="Id of the rated artefact (semantics depend on subject_type).",
|
||||
)
|
||||
subject_key = fields.Char(
|
||||
compute="_compute_subject_key", store=True, index=True,
|
||||
help="Denormalised '<type>:<id>' key for quick lookups/aggregations.",
|
||||
)
|
||||
|
||||
# Optional link to the AI call that produced this artefact. When set, it
|
||||
# lets us correlate feedback with the underlying model/prompt used.
|
||||
ai_log_id = fields.Many2one(
|
||||
"encoach.ai.log", ondelete="set null", index=True,
|
||||
)
|
||||
prompt_key = fields.Char(
|
||||
index=True,
|
||||
help="If the artefact was produced via encoach.ai.prompt, record the "
|
||||
"key here so we can compute win-rates per prompt.",
|
||||
)
|
||||
prompt_version = fields.Integer()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rating
|
||||
# ------------------------------------------------------------------
|
||||
rating = fields.Selection(
|
||||
[("up", "Thumbs up"), ("down", "Thumbs down")],
|
||||
required=True, index=True,
|
||||
)
|
||||
comment = fields.Text(
|
||||
help="Free-text note. Required when rating is 'down' and the UI "
|
||||
"prompts the student for a reason.",
|
||||
)
|
||||
tags = fields.Char(
|
||||
help="Comma-separated tags (e.g. 'wrong-answer,confusing-stem').",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Who / context
|
||||
# ------------------------------------------------------------------
|
||||
user_id = fields.Many2one(
|
||||
"res.users", required=True, index=True,
|
||||
default=lambda self: self.env.user,
|
||||
)
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity", ondelete="set null", index=True,
|
||||
)
|
||||
course_id = fields.Many2one(
|
||||
"encoach.course", ondelete="set null", index=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Triage status (filled in by admins/coaches reviewing feedback).
|
||||
# ------------------------------------------------------------------
|
||||
status = fields.Selection(
|
||||
[
|
||||
("open", "Open"),
|
||||
("acknowledged", "Acknowledged"),
|
||||
("fixed", "Fixed"),
|
||||
("dismissed", "Dismissed"),
|
||||
],
|
||||
default="open", index=True,
|
||||
)
|
||||
resolved_by_id = fields.Many2one(
|
||||
"res.users", ondelete="set null",
|
||||
)
|
||||
resolved_at = fields.Datetime()
|
||||
resolution_notes = fields.Text()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Constraints
|
||||
# ------------------------------------------------------------------
|
||||
_sql_constraints = [
|
||||
(
|
||||
"uniq_user_subject",
|
||||
"unique(user_id, subject_type, subject_id)",
|
||||
"A user can only leave one feedback row per AI artefact. "
|
||||
"Subsequent submissions should update the existing row.",
|
||||
),
|
||||
]
|
||||
|
||||
@api.depends("subject_type", "subject_id")
|
||||
def _compute_subject_key(self):
|
||||
for rec in self:
|
||||
rec.subject_key = f"{rec.subject_type or ''}:{rec.subject_id or 0}"
|
||||
|
||||
@api.constrains("subject_type", "subject_id")
|
||||
def _check_subject(self):
|
||||
for rec in self:
|
||||
if not rec.subject_type or not rec.subject_id:
|
||||
raise ValidationError("subject_type and subject_id are required.")
|
||||
213
backend/custom_addons/encoach_ai/models/ai_prompt.py
Normal file
213
backend/custom_addons/encoach_ai/models/ai_prompt.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Versioned AI prompt templates.
|
||||
|
||||
Context (ADR-0004, P3.4 hardening release): we want non-engineers to iterate
|
||||
on LLM prompts without shipping code. Every time an author edits a prompt we
|
||||
keep the previous revision around so we can A/B test, audit, and roll back.
|
||||
|
||||
Design:
|
||||
|
||||
* A **prompt** is identified by its ``key`` (e.g. ``exam.mcq.generate``) — a
|
||||
stable, dotted string that callers reference from Python.
|
||||
* Each save produces a new **version** row (bumped ``version`` int). The old
|
||||
rows stay with ``is_active = False`` so history is preserved; only one row
|
||||
per ``key`` can be active at a time (enforced in ``write``/``create``).
|
||||
* Callers look the prompt up with ``get_active(key)`` and render it with
|
||||
``render(variables)``. Rendering uses :py:meth:`str.format_map` so templates
|
||||
are just ``{variable}`` placeholders — no new DSL to learn.
|
||||
|
||||
This module intentionally does **not** call the LLM itself. It is a
|
||||
content-management layer that the existing ``encoach_ai.services.*`` pipelines
|
||||
can adopt at their own pace (falling back to their hard-coded strings until a
|
||||
prompt with the matching key is created in the UI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys look like ``namespace.sub_namespace.name`` — lowercase, dotted.
|
||||
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
# Variables pulled from ``{foo}`` / ``{foo.bar}`` placeholders.
|
||||
_VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}")
|
||||
|
||||
|
||||
class EncoachAIPrompt(models.Model):
|
||||
_name = "encoach.ai.prompt"
|
||||
_description = "Versioned AI prompt template"
|
||||
_order = "key, version desc"
|
||||
_rec_name = "display_name"
|
||||
|
||||
key = fields.Char(
|
||||
required=True,
|
||||
index=True,
|
||||
help="Stable dotted identifier (e.g. 'exam.mcq.generate'). "
|
||||
"Callers reference this from Python.",
|
||||
)
|
||||
version = fields.Integer(required=True, default=1, index=True)
|
||||
title = fields.Char(
|
||||
required=True,
|
||||
help="Short human label shown in the admin editor.",
|
||||
)
|
||||
description = fields.Text(
|
||||
help="Author intent, expected variables, known limitations.",
|
||||
)
|
||||
content = fields.Text(
|
||||
required=True,
|
||||
help="Template body. Use {variable} placeholders; "
|
||||
"render() fills them via str.format_map.",
|
||||
)
|
||||
is_active = fields.Boolean(
|
||||
default=False, index=True,
|
||||
help="Exactly one active row per key. Creating/activating a new row "
|
||||
"automatically deactivates the previous active version.",
|
||||
)
|
||||
author_id = fields.Many2one("res.users", ondelete="set null", readonly=True)
|
||||
activated_at = fields.Datetime(readonly=True)
|
||||
|
||||
# Denormalised for quick display/search. Recomputed on save.
|
||||
variables = fields.Char(
|
||||
compute="_compute_variables", store=True,
|
||||
help="Comma-separated list of {variable} placeholders found in content.",
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute="_compute_display_name", store=True,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"key_version_uniq",
|
||||
"unique(key, version)",
|
||||
"A prompt version must be unique for its key.",
|
||||
),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computed fields
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends("content")
|
||||
def _compute_variables(self):
|
||||
for rec in self:
|
||||
if not rec.content:
|
||||
rec.variables = ""
|
||||
continue
|
||||
found = sorted(set(_VAR_RE.findall(rec.content)))
|
||||
rec.variables = ",".join(found)
|
||||
|
||||
@api.depends("key", "version", "title")
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = f"{rec.key} v{rec.version} — {rec.title or ''}".rstrip(" —")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation
|
||||
# ------------------------------------------------------------------
|
||||
@api.constrains("key")
|
||||
def _check_key_shape(self):
|
||||
for rec in self:
|
||||
if not rec.key or not _KEY_RE.match(rec.key):
|
||||
raise ValidationError(
|
||||
f"Invalid prompt key {rec.key!r}. "
|
||||
"Use lowercase dotted identifiers like 'exam.mcq.generate'."
|
||||
)
|
||||
|
||||
@api.constrains("content")
|
||||
def _check_content_non_empty(self):
|
||||
for rec in self:
|
||||
if not (rec.content or "").strip():
|
||||
raise ValidationError("Prompt content cannot be empty.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create / write — enforce "one active per key"
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# Default version = max(existing) + 1 when caller omits it.
|
||||
for vals in vals_list:
|
||||
if "version" not in vals or not vals.get("version"):
|
||||
key = vals.get("key")
|
||||
existing = self.search(
|
||||
[("key", "=", key)], order="version desc", limit=1,
|
||||
)
|
||||
vals["version"] = (existing.version + 1) if existing else 1
|
||||
vals.setdefault("author_id", self.env.user.id)
|
||||
if vals.get("is_active"):
|
||||
vals.setdefault("activated_at", fields.Datetime.now())
|
||||
records = super().create(vals_list)
|
||||
# Post-create: enforce exclusivity for any rows created as active.
|
||||
for rec in records:
|
||||
if rec.is_active:
|
||||
rec._deactivate_siblings()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if vals.get("is_active"):
|
||||
now = fields.Datetime.now()
|
||||
for rec in self:
|
||||
if rec.is_active and not rec.activated_at:
|
||||
super(EncoachAIPrompt, rec).write({"activated_at": now})
|
||||
rec._deactivate_siblings()
|
||||
return res
|
||||
|
||||
def _deactivate_siblings(self):
|
||||
for rec in self:
|
||||
if not rec.is_active:
|
||||
continue
|
||||
siblings = self.search([
|
||||
("key", "=", rec.key),
|
||||
("id", "!=", rec.id),
|
||||
("is_active", "=", True),
|
||||
])
|
||||
if siblings:
|
||||
siblings.write({"is_active": False})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (called by pipelines / controllers)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def get_active(self, key: str):
|
||||
"""Return the active prompt record for ``key`` (or an empty set)."""
|
||||
return self.sudo().search(
|
||||
[("key", "=", key), ("is_active", "=", True)], limit=1,
|
||||
)
|
||||
|
||||
def render(self, variables: dict | None = None) -> str:
|
||||
"""Fill ``{placeholders}`` using ``variables`` (dict)."""
|
||||
self.ensure_one()
|
||||
variables = variables or {}
|
||||
missing = self._missing_variables(variables.keys())
|
||||
if missing:
|
||||
raise UserError(
|
||||
f"Missing prompt variables for {self.key} v{self.version}: "
|
||||
f"{', '.join(sorted(missing))}"
|
||||
)
|
||||
try:
|
||||
return self.content.format_map(_SafeFormatDict(variables))
|
||||
except Exception as exc:
|
||||
_logger.exception("prompt render failed for %s v%s", self.key, self.version)
|
||||
raise UserError(f"Prompt render failed: {exc}") from exc
|
||||
|
||||
def _missing_variables(self, provided: Iterable[str]) -> set[str]:
|
||||
self.ensure_one()
|
||||
required = set(_VAR_RE.findall(self.content or ""))
|
||||
provided_root = {p.split(".")[0] for p in provided}
|
||||
return {v for v in required if v.split(".")[0] not in provided_root}
|
||||
|
||||
|
||||
class _SafeFormatDict(dict):
|
||||
"""Dict that returns ``''`` for dotted/nested lookups str.format can't resolve.
|
||||
|
||||
We don't advertise ``{foo.bar}`` as supported (the editor shows top-level
|
||||
variables only), but we also don't want rendering to explode if a caller
|
||||
passes an object with attributes.
|
||||
"""
|
||||
|
||||
def __missing__(self, key):
|
||||
return "{" + key + "}"
|
||||
13
backend/custom_addons/encoach_ai/models/constants.py
Normal file
13
backend/custom_addons/encoach_ai/models/constants.py
Normal 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',
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
149
backend/custom_addons/encoach_ai/services/cefr_mapper.py
Normal file
149
backend/custom_addons/encoach_ai/services/cefr_mapper.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Canonical CEFR / IELTS band / IRT theta mapper.
|
||||
|
||||
This is the SINGLE SOURCE OF TRUTH for band ↔ CEFR ↔ theta conversions across
|
||||
the platform. All modules MUST import from here rather than hand-rolling their
|
||||
own mapping tables. See §21 of docs/PROJECT_SUMMARY.md for the rationale.
|
||||
"""
|
||||
|
||||
|
||||
# IRT theta band thresholds (ability parameter → CEFR level)
|
||||
THETA_TO_CEFR = [
|
||||
(-4.0, -2.5, 'pre_a1'),
|
||||
(-2.5, -1.5, 'a1'),
|
||||
(-1.5, -0.5, 'a2'),
|
||||
(-0.5, 0.5, 'b1'),
|
||||
(0.5, 1.5, 'b2'),
|
||||
(1.5, 2.5, 'c1'),
|
||||
(2.5, 4.0, 'c2'),
|
||||
]
|
||||
|
||||
# CEFR code → canonical IELTS band centre value
|
||||
CEFR_TO_BAND = {
|
||||
'pre_a1': 2.0,
|
||||
'a1': 3.0,
|
||||
'a2': 4.0,
|
||||
'b1': 5.0,
|
||||
'b2': 6.5,
|
||||
'c1': 7.5,
|
||||
'c2': 9.0,
|
||||
}
|
||||
|
||||
# Lowercase CEFR code → human-readable label
|
||||
CEFR_LABELS = {
|
||||
'pre_a1': 'Pre-A1 (Beginner)',
|
||||
'a1': 'A1 (Elementary)',
|
||||
'a2': 'A2 (Pre-Intermediate)',
|
||||
'b1': 'B1 (Intermediate)',
|
||||
'b2': 'B2 (Upper-Intermediate)',
|
||||
'c1': 'C1 (Advanced)',
|
||||
'c2': 'C2 (Proficient)',
|
||||
}
|
||||
|
||||
# Monotonic (highest band → lowest) thresholds used by band_to_cefr.
|
||||
# Picked to align CEFR_TO_BAND with widely-published IELTS/CEFR crosswalks
|
||||
# (Cambridge, Council of Europe): 8.0+ → C2, 7.0-7.9 → C1, 5.5-6.9 → B2, etc.
|
||||
_BAND_THRESHOLDS = [
|
||||
(8.0, 'c2'),
|
||||
(7.0, 'c1'),
|
||||
(5.5, 'b2'),
|
||||
(4.5, 'b1'),
|
||||
(3.5, 'a2'),
|
||||
(2.5, 'a1'),
|
||||
(0.0, 'pre_a1'),
|
||||
]
|
||||
|
||||
|
||||
def band_to_cefr(band):
|
||||
"""Map an IELTS band (0-9, may be float) to a canonical CEFR code."""
|
||||
if band is None:
|
||||
return None
|
||||
try:
|
||||
b = float(band)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for threshold, level in _BAND_THRESHOLDS:
|
||||
if b >= threshold:
|
||||
return level
|
||||
return 'pre_a1'
|
||||
|
||||
|
||||
def theta_to_cefr(theta):
|
||||
"""Map an IRT theta ability parameter (typically -4..+4) to a CEFR code."""
|
||||
try:
|
||||
t = float(theta)
|
||||
except (TypeError, ValueError):
|
||||
return 'pre_a1'
|
||||
for low, high, level in THETA_TO_CEFR:
|
||||
if low <= t < high:
|
||||
return level
|
||||
return 'c2' if t >= 2.5 else 'pre_a1'
|
||||
|
||||
|
||||
def theta_to_band(theta):
|
||||
"""Interpolate an IRT theta to an IELTS band, rounded to nearest 0.5."""
|
||||
try:
|
||||
t = float(theta)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
cefr = theta_to_cefr(t)
|
||||
base_band = CEFR_TO_BAND.get(cefr, 5.0)
|
||||
for low, high, level in THETA_TO_CEFR:
|
||||
if level == cefr:
|
||||
width = high - low
|
||||
position = (t - low) / width if width > 0 else 0.5
|
||||
cefr_list = list(CEFR_TO_BAND.keys())
|
||||
idx = cefr_list.index(cefr)
|
||||
next_band = CEFR_TO_BAND.get(
|
||||
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
|
||||
)
|
||||
band = base_band + position * (next_band - base_band)
|
||||
return round(band * 2) / 2
|
||||
return base_band
|
||||
|
||||
|
||||
def cefr_to_band(cefr):
|
||||
"""Return the canonical centre-of-range band for a CEFR code."""
|
||||
if not cefr:
|
||||
return None
|
||||
return CEFR_TO_BAND.get(str(cefr).strip().lower().replace('-', '_'))
|
||||
|
||||
|
||||
def normalize_cefr(label):
|
||||
"""Canonicalise a CEFR label to the uppercase display form (e.g. 'A2', 'Pre-A1').
|
||||
|
||||
Accepts 'a2', 'A2', 'pre_a1', 'Pre-A1', 'pre a1' etc. Returns ``None``
|
||||
if the input is falsy; returns the input unchanged if unrecognised.
|
||||
"""
|
||||
if not label:
|
||||
return None
|
||||
s = str(label).strip().lower().replace('-', '_').replace(' ', '_')
|
||||
return {
|
||||
'pre_a1': 'Pre-A1',
|
||||
'a1': 'A1',
|
||||
'a2': 'A2',
|
||||
'b1': 'B1',
|
||||
'b2': 'B2',
|
||||
'c1': 'C1',
|
||||
'c2': 'C2',
|
||||
}.get(s, label)
|
||||
|
||||
|
||||
def cefr_label(cefr_code):
|
||||
"""Return the human-readable label (e.g. 'B2 (Upper-Intermediate)') for a code."""
|
||||
return CEFR_LABELS.get(cefr_code, cefr_code)
|
||||
|
||||
|
||||
class CefrMapper:
|
||||
"""Class-style facade kept for backward compatibility with ``encoach_placement``."""
|
||||
|
||||
THETA_TO_CEFR = THETA_TO_CEFR
|
||||
CEFR_TO_BAND = CEFR_TO_BAND
|
||||
CEFR_LABELS = CEFR_LABELS
|
||||
|
||||
theta_to_cefr = staticmethod(theta_to_cefr)
|
||||
theta_to_band = staticmethod(theta_to_band)
|
||||
band_to_cefr = staticmethod(band_to_cefr)
|
||||
|
||||
@staticmethod
|
||||
def get_cefr_label(cefr_code):
|
||||
return cefr_label(cefr_code)
|
||||
@@ -20,16 +20,20 @@ class OpenAIService:
|
||||
self._get_param = env["ir.config_parameter"].sudo().get_param
|
||||
self.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
|
||||
|
||||
318
backend/custom_addons/encoach_ai/services/question_validator.py
Normal file
318
backend/custom_addons/encoach_ai/services/question_validator.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""Validation + quality gate for AI-generated exam content.
|
||||
|
||||
This module is the single entry point used by exam-generation controllers to:
|
||||
|
||||
1. **Schema-validate** raw LLM output before any DB insert (``validate_payload``),
|
||||
catching malformed AI responses (missing prompt, empty options for MCQ,
|
||||
wrong correct_answer format, etc.) before they corrupt the question pool.
|
||||
|
||||
2. **Score content quality** of persisted questions (``score_question``), wrapping
|
||||
:class:`encoach_quality_gate.services.quality_checker.QualityChecker` and
|
||||
:class:`encoach_ielts_validation.services.validator.IeltsValidator` with
|
||||
graceful degradation when those addons are not installed or their optional
|
||||
Python deps (``textstat``, ``language_tool_python``) are missing.
|
||||
|
||||
Design choices:
|
||||
|
||||
- Zero hard dependency on ``jsonschema`` or ``pydantic`` — the schema is a
|
||||
lightweight, introspectable dict used by :func:`_validate_dict`. Good enough
|
||||
for the shapes we actually emit; keeps our external-deps footprint small.
|
||||
- Quality scoring is best-effort and **never** raises to callers — failures
|
||||
degrade to a ``warn`` verdict so the HTTP request still completes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
VERDICT_OK = "ok"
|
||||
VERDICT_WARN = "warn"
|
||||
VERDICT_FAIL = "fail"
|
||||
|
||||
MIN_STEM_LEN = 8
|
||||
MIN_QUALITY_SCORE = 0.55
|
||||
|
||||
_VALID_QUESTION_TYPES = {
|
||||
"mcq", "mcq_multi", "tfng", "ynng", "gap_fill", "short_answer",
|
||||
"form_completion", "note_completion", "map_labelling", "matching",
|
||||
"summary_completion", "heading_matching", "matching_features",
|
||||
"numerical", "expression", "code_completion", "code_output",
|
||||
"sql_query", "matrix", "graph",
|
||||
}
|
||||
|
||||
_SKILL_VALUES = {
|
||||
"listening", "reading", "writing", "speaking", "grammar",
|
||||
"vocabulary", "math", "it",
|
||||
}
|
||||
|
||||
|
||||
EXERCISE_SCHEMA: dict[str, dict[str, Any]] = {
|
||||
"prompt": {"type": str, "required": True, "min_len": MIN_STEM_LEN},
|
||||
"type": {"type": str, "required": False, "choices": _VALID_QUESTION_TYPES | {
|
||||
"multiple_choice", "true_false", "fill_blanks", "matching_headings",
|
||||
"paragraph_match", "sentence_completion", "matching_information",
|
||||
}},
|
||||
"options": {"type": list, "required": False},
|
||||
"correct_answer": {"type": (str, list, int, float), "required": False},
|
||||
"marks": {"type": (int, float), "required": False, "min": 0, "max": 20},
|
||||
"cefr_level": {"type": str, "required": False},
|
||||
}
|
||||
|
||||
MODULE_SCHEMA: dict[str, dict[str, Any]] = {
|
||||
"difficulty": {"type": (list, str), "required": False},
|
||||
"timer": {"type": (int, float), "required": False, "min": 0, "max": 600},
|
||||
"totalMarks": {"type": (int, float), "required": False, "min": 0, "max": 500},
|
||||
"passages": {"type": list, "required": False},
|
||||
"sections": {"type": list, "required": False},
|
||||
"tasks": {"type": list, "required": False},
|
||||
"parts": {"type": list, "required": False},
|
||||
}
|
||||
|
||||
|
||||
def _validate_dict(obj: Any, schema: dict, path: str = "") -> list[str]:
|
||||
"""Return list of human-readable validation errors for ``obj`` against ``schema``."""
|
||||
errors: list[str] = []
|
||||
if not isinstance(obj, dict):
|
||||
errors.append(f"{path or 'root'}: expected object, got {type(obj).__name__}")
|
||||
return errors
|
||||
|
||||
for key, rule in schema.items():
|
||||
here = f"{path}.{key}" if path else key
|
||||
if key not in obj or obj[key] in (None, ""):
|
||||
if rule.get("required"):
|
||||
errors.append(f"{here}: missing required field")
|
||||
continue
|
||||
|
||||
val = obj[key]
|
||||
expected_type = rule.get("type")
|
||||
if expected_type and not isinstance(val, expected_type):
|
||||
got = type(val).__name__
|
||||
want = (
|
||||
expected_type.__name__ if isinstance(expected_type, type)
|
||||
else "/".join(t.__name__ for t in expected_type)
|
||||
)
|
||||
errors.append(f"{here}: expected {want}, got {got}")
|
||||
continue
|
||||
|
||||
if isinstance(val, str):
|
||||
min_len = rule.get("min_len")
|
||||
if min_len and len(val.strip()) < min_len:
|
||||
errors.append(f"{here}: too short (< {min_len} chars)")
|
||||
choices = rule.get("choices")
|
||||
if choices and val not in choices:
|
||||
errors.append(f"{here}: '{val}' not in allowed values")
|
||||
if isinstance(val, (int, float)):
|
||||
if "min" in rule and val < rule["min"]:
|
||||
errors.append(f"{here}: {val} < min {rule['min']}")
|
||||
if "max" in rule and val > rule["max"]:
|
||||
errors.append(f"{here}: {val} > max {rule['max']}")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_exercise(ex: Any, path: str = "exercise") -> list[str]:
|
||||
"""Validate a single AI-generated exercise dict."""
|
||||
errors = _validate_dict(ex, EXERCISE_SCHEMA, path)
|
||||
if errors:
|
||||
return errors
|
||||
q_type = (ex.get("type") or "mcq").lower()
|
||||
options = ex.get("options") or []
|
||||
correct = ex.get("correct_answer")
|
||||
if q_type in ("mcq", "multiple_choice"):
|
||||
if not options or len(options) < 2:
|
||||
errors.append(f"{path}.options: MCQ requires >= 2 options")
|
||||
if correct in (None, ""):
|
||||
errors.append(f"{path}.correct_answer: MCQ requires a correct_answer")
|
||||
if q_type in ("mcq_multi",):
|
||||
if not isinstance(correct, list) or len(correct) < 1:
|
||||
errors.append(f"{path}.correct_answer: mcq_multi requires a list of answers")
|
||||
if q_type in ("true_false", "tfng", "ynng"):
|
||||
if isinstance(correct, str) and correct.lower() not in {
|
||||
"true", "false", "not_given", "yes", "no", "t", "f"
|
||||
}:
|
||||
errors.append(f"{path}.correct_answer: '{correct}' not a valid T/F/NG answer")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_payload(payload: Any) -> dict[str, Any]:
|
||||
"""Validate an entire exam-generation submit payload.
|
||||
|
||||
Expected shape (best-effort, since the frontend already normalizes it):
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
{
|
||||
"title": str (required),
|
||||
"modules": {
|
||||
"<skill>": {
|
||||
"passages": [{"text": str, "exercises": [ExerciseSchema, ...]}],
|
||||
"sections": [...], "tasks": [...], "parts": [...],
|
||||
...
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Returns a dict with::
|
||||
|
||||
{"verdict": "ok"|"warn"|"fail", "errors": [...], "warnings": [...]}
|
||||
|
||||
``verdict == "fail"`` means the caller MUST NOT persist this payload as-is.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return {"verdict": VERDICT_FAIL,
|
||||
"errors": [f"payload: expected object, got {type(payload).__name__}"],
|
||||
"warnings": []}
|
||||
|
||||
title = (payload.get("title") or "").strip()
|
||||
if not title:
|
||||
errors.append("title: missing required field")
|
||||
|
||||
modules = payload.get("modules") or {}
|
||||
if not isinstance(modules, dict) or not modules:
|
||||
errors.append("modules: missing or empty — nothing to persist")
|
||||
return {"verdict": VERDICT_FAIL, "errors": errors, "warnings": warnings}
|
||||
|
||||
total_exercises = 0
|
||||
for mod_key, mod in modules.items():
|
||||
mpath = f"modules.{mod_key}"
|
||||
if not isinstance(mod, dict):
|
||||
errors.append(f"{mpath}: expected object")
|
||||
continue
|
||||
errors.extend(_validate_dict(mod, MODULE_SCHEMA, mpath))
|
||||
for p_idx, passage in enumerate(mod.get("passages") or []):
|
||||
if not isinstance(passage, dict):
|
||||
errors.append(f"{mpath}.passages[{p_idx}]: expected object")
|
||||
continue
|
||||
for e_idx, ex in enumerate(passage.get("exercises") or []):
|
||||
total_exercises += 1
|
||||
errors.extend(_validate_exercise(
|
||||
ex, f"{mpath}.passages[{p_idx}].exercises[{e_idx}]"))
|
||||
for s_idx, sec in enumerate(mod.get("sections") or []):
|
||||
if not isinstance(sec, dict):
|
||||
errors.append(f"{mpath}.sections[{s_idx}]: expected object")
|
||||
continue
|
||||
for e_idx, ex in enumerate(sec.get("exercises") or []):
|
||||
total_exercises += 1
|
||||
errors.extend(_validate_exercise(
|
||||
ex, f"{mpath}.sections[{s_idx}].exercises[{e_idx}]"))
|
||||
|
||||
if total_exercises == 0 and not any(
|
||||
(m.get("tasks") or m.get("parts")) for m in modules.values() if isinstance(m, dict)
|
||||
):
|
||||
warnings.append("no exercises/tasks/parts found — exam will have no questions")
|
||||
|
||||
if errors:
|
||||
verdict = VERDICT_FAIL
|
||||
elif warnings:
|
||||
verdict = VERDICT_WARN
|
||||
else:
|
||||
verdict = VERDICT_OK
|
||||
return {"verdict": verdict, "errors": errors, "warnings": warnings,
|
||||
"total_exercises": total_exercises}
|
||||
|
||||
|
||||
def prompt_hash(prompt: str) -> str:
|
||||
"""SHA-256 hex digest of a rendered prompt, for provenance tracking."""
|
||||
return hashlib.sha256((prompt or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def score_question(stem: str, *, skill: str | None = None,
|
||||
cefr_level: str | None = None) -> dict[str, Any]:
|
||||
"""Best-effort quality score for a persisted question stem.
|
||||
|
||||
Wraps QualityChecker + IeltsValidator defensively — any import/runtime
|
||||
failure downgrades the verdict to ``warn`` without raising. Returns::
|
||||
|
||||
{
|
||||
"verdict": "ok"|"warn"|"fail",
|
||||
"score": float in [0, 1],
|
||||
"readability": {...} | None,
|
||||
"grammar_issues": int,
|
||||
"checks_ran": ["readability", "grammar", ...],
|
||||
"errors": [str, ...],
|
||||
}
|
||||
"""
|
||||
report: dict[str, Any] = {
|
||||
"verdict": VERDICT_OK,
|
||||
"score": 1.0,
|
||||
"readability": None,
|
||||
"grammar_issues": 0,
|
||||
"checks_ran": [],
|
||||
"errors": [],
|
||||
}
|
||||
if not stem or len(stem.strip()) < MIN_STEM_LEN:
|
||||
report["verdict"] = VERDICT_FAIL
|
||||
report["score"] = 0.0
|
||||
report["errors"].append(f"stem shorter than {MIN_STEM_LEN} chars")
|
||||
return report
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_quality_gate.services.quality_checker import QualityChecker
|
||||
except Exception as exc:
|
||||
report["verdict"] = VERDICT_WARN
|
||||
report["errors"].append(f"quality_gate unavailable: {exc}")
|
||||
return report
|
||||
|
||||
try:
|
||||
readability = QualityChecker.check_readability(stem)
|
||||
report["readability"] = readability
|
||||
report["checks_ran"].append("readability")
|
||||
if cefr_level:
|
||||
cefr_match = QualityChecker.check_cefr_alignment(stem, cefr_level.lower())
|
||||
report["cefr_alignment"] = cefr_match
|
||||
report["checks_ran"].append("cefr_alignment")
|
||||
if not cefr_match.get("aligned", True):
|
||||
report["score"] -= 0.2
|
||||
grammar = QualityChecker.check_grammar(stem)
|
||||
report["grammar_issues"] = len(grammar.get("issues", [])) if isinstance(grammar, dict) else 0
|
||||
report["checks_ran"].append("grammar")
|
||||
if report["grammar_issues"] > 3:
|
||||
report["score"] -= 0.2
|
||||
except Exception as exc:
|
||||
_logger.warning("QualityChecker failed for stem (skill=%s): %s", skill, exc)
|
||||
report["verdict"] = VERDICT_WARN
|
||||
report["errors"].append(f"quality_checker error: {exc}")
|
||||
|
||||
report["score"] = max(0.0, min(1.0, report["score"]))
|
||||
if report["verdict"] == VERDICT_OK and report["score"] < MIN_QUALITY_SCORE:
|
||||
report["verdict"] = VERDICT_WARN
|
||||
return report
|
||||
|
||||
|
||||
def summarize_question_reports(reports: list[dict]) -> dict[str, Any]:
|
||||
"""Reduce a list of per-question reports to an aggregate verdict."""
|
||||
if not reports:
|
||||
return {"verdict": VERDICT_OK, "total": 0, "failed": 0,
|
||||
"warned": 0, "avg_score": 1.0}
|
||||
failed = sum(1 for r in reports if r.get("verdict") == VERDICT_FAIL)
|
||||
warned = sum(1 for r in reports if r.get("verdict") == VERDICT_WARN)
|
||||
avg = sum(float(r.get("score") or 0) for r in reports) / max(1, len(reports))
|
||||
if failed:
|
||||
verdict = VERDICT_FAIL
|
||||
elif warned:
|
||||
verdict = VERDICT_WARN
|
||||
else:
|
||||
verdict = VERDICT_OK
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"total": len(reports),
|
||||
"failed": failed,
|
||||
"warned": warned,
|
||||
"avg_score": round(avg, 3),
|
||||
}
|
||||
|
||||
|
||||
def dumps_report(report: dict) -> str:
|
||||
"""Compact JSON dump safe for storing in a Text column."""
|
||||
try:
|
||||
return json.dumps(report, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return "{}"
|
||||
@@ -1 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import utils # exposes odoo.addons.encoach_api.utils.cache
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
'external_dependencies': {
|
||||
'python': ['PyJWT'],
|
||||
},
|
||||
'data': [],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
from . import base
|
||||
from . import auth
|
||||
from . import health
|
||||
from . import openapi
|
||||
from . import gdpr
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
"""Authentication endpoints.
|
||||
|
||||
Split-token design:
|
||||
|
||||
* ``access_token`` — short-lived (1h), stateless, verified via signature only.
|
||||
Sent on every request as ``Authorization: Bearer <access_token>``.
|
||||
* ``refresh_token`` — long-lived (7d), stateful, backed by
|
||||
``encoach.jwt.token``. Never sent on regular API calls; only to
|
||||
``/api/auth/refresh``. Rotated on every refresh so a leaked token is
|
||||
usable at most once.
|
||||
|
||||
The frontend should store ``refresh_token`` in a secure, non-extractable
|
||||
location (ideally ``httpOnly`` cookie if the deployment proxy allows it,
|
||||
otherwise ``localStorage`` behind an explicit trust boundary) and call
|
||||
``/api/auth/refresh`` in the background whenever the access token is within
|
||||
2 minutes of expiry.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
@@ -13,6 +33,40 @@ from .base import (
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_TTL_SECONDS = 3600 # 1 hour
|
||||
REFRESH_TTL_SECONDS = 7 * 86400 # 7 days
|
||||
|
||||
|
||||
def _issue_tokens(user, *, user_agent=None, remote_ip=None):
|
||||
"""Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple.
|
||||
|
||||
Persisting the refresh token is the caller's job — they get the ``jti``
|
||||
back so they can insert exactly one row.
|
||||
"""
|
||||
secret = _get_jwt_secret()
|
||||
now = int(time.time())
|
||||
access_token = pyjwt.encode(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + ACCESS_TTL_SECONDS,
|
||||
},
|
||||
secret, algorithm="HS256",
|
||||
)
|
||||
refresh_jti = uuid.uuid4().hex
|
||||
refresh_token = pyjwt.encode(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"type": "refresh",
|
||||
"jti": refresh_jti,
|
||||
"iat": now,
|
||||
"exp": now + REFRESH_TTL_SECONDS,
|
||||
},
|
||||
secret, algorithm="HS256",
|
||||
)
|
||||
return access_token, refresh_token, refresh_jti
|
||||
|
||||
|
||||
class EncoachAuthController(http.Controller):
|
||||
|
||||
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
|
||||
if not login or not password:
|
||||
return _error_response('login and password are required', 400)
|
||||
|
||||
# Odoo 19: session.authenticate(env, credential_dict)
|
||||
credential = {
|
||||
'type': 'password',
|
||||
'login': login,
|
||||
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
|
||||
|
||||
user = request.env['res.users'].sudo().browse(uid)
|
||||
|
||||
# Generate JWT token
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
return _error_response('JWT not configured on server', 500)
|
||||
|
||||
token = pyjwt.encode(
|
||||
{'user_id': user.id, 'exp': int(time.time()) + 86400},
|
||||
secret, algorithm='HS256',
|
||||
)
|
||||
# User-Agent / IP are best-effort — some proxies strip them; we
|
||||
# record whatever we get but never refuse login because they're
|
||||
# missing.
|
||||
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
|
||||
ip = request.httprequest.remote_addr if request.httprequest else ''
|
||||
|
||||
access_token, refresh_token, refresh_jti = _issue_tokens(
|
||||
user, user_agent=ua, remote_ip=ip,
|
||||
)
|
||||
request.env['encoach.jwt.token'].sudo().create({
|
||||
'jti': refresh_jti,
|
||||
'user_id': user.id,
|
||||
'issued_at': fields.Datetime.now(),
|
||||
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
|
||||
'user_agent': (ua or '')[:255],
|
||||
'remote_ip': (ip or '')[:64],
|
||||
})
|
||||
|
||||
# Get permissions
|
||||
permissions = []
|
||||
if hasattr(user, 'get_all_permissions'):
|
||||
permissions = user.get_all_permissions().mapped('code')
|
||||
|
||||
# Update last login
|
||||
user.write({'last_login': fields.Datetime.now()})
|
||||
|
||||
return _json_response({
|
||||
'token': token,
|
||||
'token': access_token, # legacy name (frontend fallback)
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'expires_in': ACCESS_TTL_SECONDS,
|
||||
'refresh_expires_in': REFRESH_TTL_SECONDS,
|
||||
'token_type': 'Bearer',
|
||||
'user': self._user_to_dict(user),
|
||||
'permissions': permissions,
|
||||
})
|
||||
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
|
||||
_logger.exception('login failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/auth/refresh
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/auth/refresh', type='http', auth='public',
|
||||
methods=['POST'], csrf=False)
|
||||
def refresh(self, **kw):
|
||||
"""Rotate a refresh token into a fresh access+refresh pair.
|
||||
|
||||
Consumes (revokes) the presented refresh token and issues new ones.
|
||||
This is the *only* place a refresh token is ever accepted, so replay
|
||||
attempts against other endpoints fail at the access-token layer.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
token = body.get('refresh_token') or ''
|
||||
if not token:
|
||||
return _error_response('refresh_token is required', 400)
|
||||
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
return _error_response('JWT not configured on server', 500)
|
||||
|
||||
try:
|
||||
claims = pyjwt.decode(token, secret, algorithms=['HS256'])
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
return _error_response('refresh_token expired', 401)
|
||||
except pyjwt.InvalidTokenError:
|
||||
return _error_response('refresh_token invalid', 401)
|
||||
|
||||
if claims.get('type') != 'refresh':
|
||||
return _error_response('wrong token type', 401)
|
||||
|
||||
jti = claims.get('jti')
|
||||
user_id = claims.get('user_id')
|
||||
if not jti or not user_id:
|
||||
return _error_response('refresh_token malformed', 401)
|
||||
|
||||
Token = request.env['encoach.jwt.token'].sudo()
|
||||
row = Token.search([
|
||||
('jti', '=', jti),
|
||||
('user_id', '=', user_id),
|
||||
('revoked', '=', False),
|
||||
], limit=1)
|
||||
if not row:
|
||||
# Either already rotated (possible replay) or never existed.
|
||||
# Revoke every active token for that user as a defensive
|
||||
# measure — stolen refresh tokens shouldn't buy multiple
|
||||
# rotations.
|
||||
Token.search([
|
||||
('user_id', '=', user_id),
|
||||
('revoked', '=', False),
|
||||
]).write({'revoked': True})
|
||||
return _error_response('refresh_token revoked', 401)
|
||||
|
||||
user = request.env['res.users'].sudo().browse(user_id)
|
||||
if not user.exists() or not user.active:
|
||||
return _error_response('user disabled', 401)
|
||||
|
||||
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
|
||||
ip = request.httprequest.remote_addr if request.httprequest else ''
|
||||
access_token, refresh_token, refresh_jti = _issue_tokens(
|
||||
user, user_agent=ua, remote_ip=ip,
|
||||
)
|
||||
row.write({'revoked': True, 'last_used_at': fields.Datetime.now()})
|
||||
Token.create({
|
||||
'jti': refresh_jti,
|
||||
'user_id': user.id,
|
||||
'issued_at': fields.Datetime.now(),
|
||||
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
|
||||
'user_agent': (ua or '')[:255],
|
||||
'remote_ip': (ip or '')[:64],
|
||||
})
|
||||
return _json_response({
|
||||
'access_token': access_token,
|
||||
'token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'expires_in': ACCESS_TTL_SECONDS,
|
||||
'refresh_expires_in': REFRESH_TTL_SECONDS,
|
||||
'token_type': 'Bearer',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('refresh failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/user (returns current authenticated user)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
|
||||
@http.route('/api/logout', type='http', auth='public',
|
||||
methods=['POST'], csrf=False)
|
||||
def logout(self, **kw):
|
||||
# JWT is stateless — client clears token. Server just returns OK.
|
||||
"""Revoke the presented refresh token (if any) and return OK.
|
||||
|
||||
Access tokens remain stateless; they expire naturally within an hour.
|
||||
Clients that care about immediate lockout should also delete the
|
||||
access token from local storage when they call this endpoint.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
token = body.get('refresh_token') or ''
|
||||
if token:
|
||||
secret = _get_jwt_secret()
|
||||
if secret:
|
||||
try:
|
||||
claims = pyjwt.decode(
|
||||
token, secret, algorithms=['HS256'],
|
||||
options={'verify_exp': False},
|
||||
)
|
||||
jti = claims.get('jti')
|
||||
if jti:
|
||||
request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti)
|
||||
except pyjwt.InvalidTokenError:
|
||||
# Invalid tokens can't be revoked but also can't be
|
||||
# reused, so this is effectively a no-op.
|
||||
pass
|
||||
except Exception:
|
||||
_logger.exception('logout cleanup failed')
|
||||
return _json_response({'ok': True})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
@@ -9,14 +10,75 @@ from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_ID_HEADER = "X-Request-Id"
|
||||
|
||||
|
||||
def _get_or_create_request_id():
|
||||
"""Return the X-Request-Id for the current request, minting one if absent.
|
||||
|
||||
The value is cached on ``request`` so the same id is reused for every log
|
||||
line emitted during the request lifecycle (dispatcher, controller,
|
||||
post-dispatch). Call sites should prefer :func:`current_request_id`.
|
||||
"""
|
||||
existing = getattr(request, "_encoach_request_id", None)
|
||||
if existing:
|
||||
return existing
|
||||
header_val = request.httprequest.headers.get(REQUEST_ID_HEADER)
|
||||
req_id = (header_val or uuid.uuid4().hex)[:64]
|
||||
try:
|
||||
request._encoach_request_id = req_id
|
||||
except Exception:
|
||||
pass
|
||||
return req_id
|
||||
|
||||
|
||||
def current_request_id():
|
||||
"""Best-effort accessor for the active request id (None outside a request)."""
|
||||
try:
|
||||
return getattr(request, "_encoach_request_id", None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _log_json(level, event, **fields):
|
||||
"""Emit a single structured JSON log line, enriched with the request id.
|
||||
|
||||
Keeping all non-interactive logs JSON-formatted lets ops scrape them with
|
||||
Loki / OpenSearch without brittle regexes. Falls back silently if the
|
||||
logging backend rejects the payload.
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
"ts": round(time.time(), 3),
|
||||
"event": event,
|
||||
"rid": current_request_id(),
|
||||
}
|
||||
payload.update(fields)
|
||||
_logger.log(level, json.dumps(payload, default=str, ensure_ascii=False))
|
||||
except Exception:
|
||||
_logger.log(level, "%s %s", event, fields)
|
||||
|
||||
_jwt_secret_cache = {"secret": None, "ts": 0}
|
||||
_JWT_SECRET_TTL = 300
|
||||
# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation
|
||||
# via ir.config_parameter propagates quickly across all workers without
|
||||
# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21.
|
||||
_JWT_SECRET_TTL = 30
|
||||
|
||||
_user_exists_cache = {}
|
||||
_USER_CACHE_TTL = 60
|
||||
_USER_CACHE_MAX = 200
|
||||
|
||||
|
||||
def invalidate_jwt_secret_cache():
|
||||
"""Invalidate the per-worker JWT secret cache immediately.
|
||||
|
||||
Called by ``encoach.auth.settings`` whenever the admin rotates the secret
|
||||
so that subsequent requests pick up the new value on the next read.
|
||||
"""
|
||||
_jwt_secret_cache["secret"] = None
|
||||
_jwt_secret_cache["ts"] = 0
|
||||
|
||||
|
||||
def _get_jwt_secret():
|
||||
now = time.time()
|
||||
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
|
||||
@@ -48,6 +110,12 @@ def validate_token():
|
||||
return None
|
||||
except pyjwt.InvalidTokenError:
|
||||
return None
|
||||
# Refresh tokens are only accepted by /api/auth/refresh. Presenting one as
|
||||
# a Bearer on any other endpoint is treated as unauthenticated so a
|
||||
# leaked refresh token never buys API access on its own.
|
||||
token_type = payload.get("type")
|
||||
if token_type and token_type != "access":
|
||||
return None
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
@@ -70,26 +138,95 @@ def validate_token():
|
||||
|
||||
|
||||
def jwt_required(func):
|
||||
"""Decorator that validates the JWT token and sets request.env user context."""
|
||||
"""Decorator that validates the JWT token and sets request.env user context.
|
||||
|
||||
Also handles per-request observability plumbing:
|
||||
|
||||
- Assigns (or honours) an ``X-Request-Id`` so every log line, downstream
|
||||
service call, and response can be correlated back to the same request.
|
||||
- Emits a structured ``api.request.start`` / ``api.request.end`` log pair
|
||||
with duration in ms, status code, user id, route, and method — powering
|
||||
the P2.3 metrics pipeline once it lands without any extra wiring.
|
||||
- Echoes the request id back to the caller via the ``X-Request-Id``
|
||||
response header.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
req_id = _get_or_create_request_id()
|
||||
route = request.httprequest.path
|
||||
method = request.httprequest.method
|
||||
t0 = time.time()
|
||||
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _error_response("Authentication required", status=401)
|
||||
_log_json(logging.INFO, "api.request.unauthorized",
|
||||
route=route, method=method, status=401)
|
||||
resp = _error_response("Authentication required", status=401)
|
||||
try:
|
||||
resp.headers[REQUEST_ID_HEADER] = req_id
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
request.update_env(user=user.id)
|
||||
return func(*args, **kwargs)
|
||||
_log_json(logging.INFO, "api.request.start",
|
||||
route=route, method=method, user_id=user.id)
|
||||
try:
|
||||
resp = func(*args, **kwargs)
|
||||
status = getattr(resp, "status_code", 200)
|
||||
duration_ms = int((time.time() - t0) * 1000)
|
||||
_log_json(logging.INFO, "api.request.end",
|
||||
route=route, method=method, user_id=user.id,
|
||||
status=status, duration_ms=duration_ms)
|
||||
try:
|
||||
resp.headers[REQUEST_ID_HEADER] = req_id
|
||||
except Exception:
|
||||
pass
|
||||
_record_metric(route, status, duration_ms)
|
||||
return resp
|
||||
except Exception as exc:
|
||||
duration_ms = int((time.time() - t0) * 1000)
|
||||
_log_json(logging.ERROR, "api.request.error",
|
||||
route=route, method=method, user_id=user.id,
|
||||
duration_ms=duration_ms,
|
||||
error=type(exc).__name__, message=str(exc)[:500])
|
||||
_record_metric(route, 500, duration_ms)
|
||||
raise
|
||||
return wrapper
|
||||
|
||||
|
||||
def _record_metric(route, status, duration_ms):
|
||||
"""Soft import of the metrics recorder so this module has no hard cycle."""
|
||||
try:
|
||||
from odoo.addons.encoach_api.controllers.openapi import record_request
|
||||
record_request(route, status, duration_ms)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return request.make_json_response(data, status=status)
|
||||
resp = request.make_json_response(data, status=status)
|
||||
try:
|
||||
rid = current_request_id()
|
||||
if rid:
|
||||
resp.headers[REQUEST_ID_HEADER] = rid
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
def _error_response(message, status=400, code=None):
|
||||
body = {"error": message}
|
||||
if code:
|
||||
body["code"] = code
|
||||
return request.make_json_response(body, status=status)
|
||||
resp = request.make_json_response(body, status=status)
|
||||
try:
|
||||
rid = current_request_id()
|
||||
if rid:
|
||||
resp.headers[REQUEST_ID_HEADER] = rid
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
def _get_json_body():
|
||||
@@ -99,6 +236,31 @@ def _get_json_body():
|
||||
return {}
|
||||
|
||||
|
||||
def paginated_envelope(items, *, total=None, page=None, size=None, extra=None):
|
||||
"""Canonical ``{items, total, page, size}`` envelope.
|
||||
|
||||
This is the platform-wide pagination shape — prefer returning the output of
|
||||
this helper from any new list endpoint. Legacy keys (``data``, ``results``,
|
||||
and custom aliases such as ``students`` / ``subjects``) may be added via
|
||||
the ``extra`` dict for transitional backward compatibility with the
|
||||
frontend services that still read those keys; new code must read ``items``.
|
||||
"""
|
||||
items = list(items or [])
|
||||
envelope = {
|
||||
"items": items,
|
||||
"total": int(total if total is not None else len(items)),
|
||||
}
|
||||
if page is not None:
|
||||
envelope["page"] = int(page)
|
||||
if size is not None:
|
||||
envelope["size"] = int(size)
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
if k not in envelope:
|
||||
envelope[k] = v
|
||||
return envelope
|
||||
|
||||
|
||||
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
|
||||
"""Paginate an Odoo model search or extract params from a kwargs dict.
|
||||
|
||||
|
||||
316
backend/custom_addons/encoach_api/controllers/gdpr.py
Normal file
316
backend/custom_addons/encoach_api/controllers/gdpr.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""GDPR data-subject rights endpoints.
|
||||
|
||||
Exposes two routes:
|
||||
|
||||
* ``GET /api/gdpr/export``
|
||||
Returns a JSON snapshot of the calling user's personal data — profile,
|
||||
entity membership, exam attempts, answers, AI interactions, feedback,
|
||||
tickets, and login history.
|
||||
|
||||
* ``POST /api/gdpr/delete``
|
||||
Initiates the "right to erasure" flow. Because Odoo enforces FK
|
||||
constraints on linked business records (exam attempts, tickets, audit
|
||||
trail), we **anonymise** instead of hard-deleting: the ``res.users``
|
||||
row is deactivated, ``res.partner`` PII fields are wiped, and a
|
||||
tombstone row is written to ``encoach.gdpr.erasure.request`` for
|
||||
audit. Hard delete of linked business rows (attempts, answers,
|
||||
feedback) is performed for records where it is legally required and
|
||||
where no other user depends on them.
|
||||
|
||||
The flow is intentionally verbose so operators can see what happened. A
|
||||
production deployment may want to queue the erasure to a background job
|
||||
and require a cooling-off period before executing; we expose a
|
||||
``confirm`` flag for that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
from .base import (
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
_json_response,
|
||||
jwt_required,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
def _safe_records(env_name: str, domain, fields_to_read):
|
||||
"""Read records if the model exists, else return []."""
|
||||
env = request.env
|
||||
if env_name not in env:
|
||||
return []
|
||||
try:
|
||||
return env[env_name].sudo().search(domain).read(fields_to_read)
|
||||
except Exception as exc:
|
||||
_logger.warning("gdpr: could not read %s: %s", env_name, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _sanitize(values):
|
||||
"""Convert non-JSON-serialisable values to strings/nulls."""
|
||||
out = {}
|
||||
for key, value in values.items():
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], int):
|
||||
# Many2one read tuples — keep both id and display name.
|
||||
out[key] = {"id": value[0], "display_name": value[1]}
|
||||
elif isinstance(value, bytes):
|
||||
out[key] = f"<{len(value)} bytes omitted>"
|
||||
elif hasattr(value, "isoformat"):
|
||||
out[key] = value.isoformat()
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Controller
|
||||
# ----------------------------------------------------------------------
|
||||
class EncoachGdprController(http.Controller):
|
||||
"""Implements export + right-to-erasure endpoints."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/gdpr/export
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/api/gdpr/export", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
@jwt_required
|
||||
def export(self, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
partner = user.partner_id
|
||||
|
||||
profile = {
|
||||
"id": user.id,
|
||||
"login": user.login,
|
||||
"name": user.name,
|
||||
"email": user.email or "",
|
||||
"lang": user.lang or "",
|
||||
"tz": user.tz or "",
|
||||
"create_date": user.create_date.isoformat() if user.create_date else None,
|
||||
"login_date": user.login_date.isoformat() if user.login_date else None,
|
||||
}
|
||||
partner_data = {
|
||||
"id": partner.id if partner else None,
|
||||
"name": partner.name if partner else None,
|
||||
"email": partner.email if partner else None,
|
||||
"phone": partner.phone if partner else None,
|
||||
"mobile": partner.mobile if partner else None,
|
||||
"street": partner.street if partner else None,
|
||||
"city": partner.city if partner else None,
|
||||
"country": partner.country_id.name if partner and partner.country_id else None,
|
||||
}
|
||||
|
||||
# Entity / role memberships
|
||||
entity_rel = _safe_records(
|
||||
"encoach.user.entity.rel",
|
||||
[("user_id", "=", user.id)],
|
||||
["id", "entity_id", "role_id", "is_active"],
|
||||
)
|
||||
|
||||
# Exam attempts & answers
|
||||
attempts = _safe_records(
|
||||
"encoach.student.attempt",
|
||||
[("user_id", "=", user.id)],
|
||||
["id", "exam_id", "status", "score", "started_at", "submitted_at"],
|
||||
)
|
||||
attempt_ids = [a["id"] for a in attempts]
|
||||
answers = _safe_records(
|
||||
"encoach.student.answer",
|
||||
[("attempt_id", "in", attempt_ids)],
|
||||
["id", "attempt_id", "question_id", "student_answer", "is_correct", "score"],
|
||||
) if attempt_ids else []
|
||||
|
||||
# AI feedback they left
|
||||
feedback = _safe_records(
|
||||
"encoach.ai.feedback",
|
||||
[("user_id", "=", user.id)],
|
||||
["id", "subject_type", "subject_id", "rating", "comment", "status", "create_date"],
|
||||
)
|
||||
|
||||
# AI calls they triggered (logs)
|
||||
ai_logs = _safe_records(
|
||||
"encoach.ai.log",
|
||||
[("user_id", "=", user.id)] if "encoach.ai.log" in request.env else [],
|
||||
["id", "service", "action", "model_used", "total_tokens", "status", "create_date"],
|
||||
)
|
||||
|
||||
# Tickets they filed
|
||||
tickets = _safe_records(
|
||||
"encoach.ticket",
|
||||
[("created_by_id", "=", user.id)],
|
||||
["id", "name", "status", "priority", "assigned_to_id", "create_date"],
|
||||
)
|
||||
|
||||
# Feedback on their coaching, if any
|
||||
coaching_sessions = _safe_records(
|
||||
"encoach.coaching.session",
|
||||
[("user_id", "=", user.id)],
|
||||
["id", "create_date", "summary"],
|
||||
)
|
||||
|
||||
payload = {
|
||||
"profile": profile,
|
||||
"partner": partner_data,
|
||||
"entity_memberships": [_sanitize(r) for r in entity_rel],
|
||||
"exam_attempts": [_sanitize(r) for r in attempts],
|
||||
"exam_answers": [_sanitize(r) for r in answers],
|
||||
"ai_feedback": [_sanitize(r) for r in feedback],
|
||||
"ai_calls": [_sanitize(r) for r in ai_logs],
|
||||
"tickets": [_sanitize(r) for r in tickets],
|
||||
"coaching_sessions": [_sanitize(r) for r in coaching_sessions],
|
||||
"exported_at": fields.Datetime.to_string(fields.Datetime.now()),
|
||||
"export_format_version": "1.0",
|
||||
}
|
||||
return _json_response(payload)
|
||||
except Exception as e:
|
||||
_logger.exception("gdpr export failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/gdpr/delete
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/api/gdpr/delete", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
@jwt_required
|
||||
def delete(self, **kw):
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
if not body.get("confirm"):
|
||||
return _error_response(
|
||||
"Set {\"confirm\": true} to initiate erasure. "
|
||||
"This action cannot be undone.",
|
||||
400,
|
||||
)
|
||||
|
||||
user = request.env.user
|
||||
if user.id == 1 or user.has_group("base.group_system"):
|
||||
return _error_response(
|
||||
"Admin accounts cannot self-erase via this endpoint. "
|
||||
"Contact the data protection officer.",
|
||||
403,
|
||||
)
|
||||
|
||||
login = user.login
|
||||
email = user.email or ""
|
||||
archived_id = user.id
|
||||
summary = {
|
||||
"anonymised_partner_fields": [],
|
||||
"deactivated_user": False,
|
||||
"deleted_feedback_count": 0,
|
||||
"deleted_coaching_count": 0,
|
||||
"retained_attempts_count": 0,
|
||||
}
|
||||
|
||||
with request.env.cr.savepoint():
|
||||
# 1. Anonymise the partner PII.
|
||||
partner = user.partner_id
|
||||
if partner:
|
||||
partner.sudo().write({
|
||||
"name": f"deleted-user-{archived_id}",
|
||||
"email": False,
|
||||
"phone": False,
|
||||
"mobile": False,
|
||||
"street": False,
|
||||
"street2": False,
|
||||
"city": False,
|
||||
"zip": False,
|
||||
"image_1920": False,
|
||||
"comment": "Erased per GDPR request.",
|
||||
})
|
||||
summary["anonymised_partner_fields"] = [
|
||||
"name", "email", "phone", "mobile",
|
||||
"street", "street2", "city", "zip",
|
||||
"image_1920", "comment",
|
||||
]
|
||||
|
||||
# 2. Hard-delete their feedback (personal reviews/comments).
|
||||
if "encoach.ai.feedback" in request.env:
|
||||
feedback = request.env["encoach.ai.feedback"].sudo().search([
|
||||
("user_id", "=", user.id),
|
||||
])
|
||||
summary["deleted_feedback_count"] = len(feedback)
|
||||
feedback.unlink()
|
||||
|
||||
# 3. Hard-delete coaching session transcripts (personal).
|
||||
if "encoach.coaching.session" in request.env:
|
||||
sessions = request.env["encoach.coaching.session"].sudo().search([
|
||||
("user_id", "=", user.id),
|
||||
])
|
||||
summary["deleted_coaching_count"] = len(sessions)
|
||||
sessions.unlink()
|
||||
|
||||
# 4. Retain exam attempts (aggregate analytics rely on them)
|
||||
# but strip personal free-text fields where they exist.
|
||||
if "encoach.student.attempt" in request.env:
|
||||
retained = request.env["encoach.student.attempt"].sudo().search([
|
||||
("user_id", "=", user.id),
|
||||
])
|
||||
summary["retained_attempts_count"] = len(retained)
|
||||
# Clear any written responses that contain user PII — keep
|
||||
# the scores/timestamps for analytics integrity.
|
||||
if "encoach.student.answer" in request.env:
|
||||
answers = request.env["encoach.student.answer"].sudo().search([
|
||||
("attempt_id", "in", retained.ids),
|
||||
])
|
||||
# student_answer can contain free-text PII.
|
||||
answers.write({"student_answer": False})
|
||||
|
||||
# 5. Rewrite AI log user_id → None so we can no longer tie
|
||||
# those rows to the person.
|
||||
if "encoach.ai.log" in request.env:
|
||||
ai_logs = request.env["encoach.ai.log"].sudo().search([
|
||||
("user_id", "=", user.id),
|
||||
])
|
||||
if ai_logs:
|
||||
try:
|
||||
ai_logs.write({"user_id": False})
|
||||
except Exception:
|
||||
# user_id may be required; fall back to deletion.
|
||||
ai_logs.unlink()
|
||||
|
||||
# 6. Deactivate the user (we can't unlink — FKs).
|
||||
user.sudo().write({
|
||||
"active": False,
|
||||
"login": f"erased-{archived_id}@example.invalid",
|
||||
})
|
||||
summary["deactivated_user"] = True
|
||||
|
||||
# 7. Write tombstone audit row.
|
||||
request.env["encoach.gdpr.erasure.request"].sudo().create({
|
||||
"user_login": login,
|
||||
"user_email": email,
|
||||
"user_id_archived": archived_id,
|
||||
"status": "completed",
|
||||
"summary": json.dumps(summary),
|
||||
"completed_at": fields.Datetime.now(),
|
||||
})
|
||||
|
||||
# Blow away any cached JWT sessions for this user.
|
||||
try:
|
||||
from odoo.addons.encoach_api.utils.jwt_cache import (
|
||||
invalidate_user as _invalidate_user,
|
||||
)
|
||||
_invalidate_user(archived_id)
|
||||
except Exception as exc:
|
||||
_logger.debug("jwt cache invalidation skipped: %s", exc)
|
||||
|
||||
return _json_response({
|
||||
"ok": True,
|
||||
"summary": summary,
|
||||
"message": (
|
||||
"Your account has been anonymised and deactivated. "
|
||||
"Some records (e.g. exam attempts) are retained "
|
||||
"without personal identifiers for statistical purposes."
|
||||
),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("gdpr delete failed")
|
||||
return _error_response(str(e), 500)
|
||||
65
backend/custom_addons/encoach_api/controllers/health.py
Normal file
65
backend/custom_addons/encoach_api/controllers/health.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Platform health probes (P0.4).
|
||||
|
||||
Exposes unauthenticated ``/api/health`` (quick liveness) and ``/api/health/ready``
|
||||
(deep readiness: DB + JWT secret + OpenAI key). Designed to be consumed by
|
||||
uptime monitors, load-balancer health checks and container orchestrators.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.release import version as odoo_version
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_STARTED_AT = time.time()
|
||||
|
||||
|
||||
class HealthController(http.Controller):
|
||||
"""Platform health probes — safe to expose publicly."""
|
||||
|
||||
@http.route("/api/health", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
def health(self, **_kw):
|
||||
"""Liveness probe: the process is up and able to handle HTTP."""
|
||||
return request.make_json_response({
|
||||
"status": "ok",
|
||||
"service": "encoach-backend",
|
||||
"odoo_version": odoo_version,
|
||||
"uptime_seconds": int(time.time() - _STARTED_AT),
|
||||
})
|
||||
|
||||
@http.route("/api/health/ready", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
def ready(self, **_kw):
|
||||
"""Readiness probe: DB reachable, JWT secret configured, AI key known."""
|
||||
checks = {}
|
||||
ok = True
|
||||
|
||||
try:
|
||||
request.env.cr.execute("SELECT 1")
|
||||
checks["database"] = "ok"
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
checks["database"] = f"error: {exc}"
|
||||
|
||||
try:
|
||||
IrParam = request.env["ir.config_parameter"].sudo()
|
||||
checks["jwt_secret"] = "ok" if IrParam.get_param("encoach.jwt_secret") else "missing"
|
||||
if checks["jwt_secret"] == "missing":
|
||||
ok = False
|
||||
ai_key = IrParam.get_param("encoach_ai.openai_api_key")
|
||||
import os
|
||||
if not ai_key:
|
||||
ai_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
checks["openai_key"] = "ok" if ai_key else "missing"
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
checks["config"] = f"error: {exc}"
|
||||
|
||||
status = 200 if ok else 503
|
||||
return request.make_json_response({
|
||||
"status": "ok" if ok else "degraded",
|
||||
"checks": checks,
|
||||
"uptime_seconds": int(time.time() - _STARTED_AT),
|
||||
}, status=status)
|
||||
265
backend/custom_addons/encoach_api/controllers/openapi.py
Normal file
265
backend/custom_addons/encoach_api/controllers/openapi.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""OpenAPI 3.0 + Prometheus-ish metrics exporter.
|
||||
|
||||
Both endpoints are **unauthenticated** by design so they can be scraped by
|
||||
third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing
|
||||
to solve a JWT exchange first. The OpenAPI spec is generated by introspecting
|
||||
every class decorated with :func:`odoo.http.route` and therefore stays in sync
|
||||
with the running server automatically.
|
||||
|
||||
Scope: the generator emits a minimal but valid OpenAPI document — routes,
|
||||
HTTP methods, path params, tag derived from the route prefix, and a shared
|
||||
``BearerAuth`` security scheme. Request / response schemas are left open
|
||||
(``{"type": "object"}``) since we don't have pydantic models yet; a future
|
||||
iteration can enrich individual routes via ``route_decorated_method.openapi``
|
||||
annotations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import Controller, Response, request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_metrics_lock = None
|
||||
_metrics: dict = {
|
||||
"started_at": time.time(),
|
||||
"routes": Counter(), # key -> hit count
|
||||
"statuses": Counter(), # status bucket -> hit count
|
||||
"latencies_ms": defaultdict(list), # route -> [recent ms samples] (cap 128)
|
||||
}
|
||||
_LATENCY_SAMPLE_CAP = 128
|
||||
_PATH_PARAM_RE = re.compile(r"<(?:(?P<conv>\w+):)?(?P<name>\w+)>")
|
||||
|
||||
|
||||
def record_request(route: str, status: int, duration_ms: int) -> None:
|
||||
"""Lightweight in-process request counter used by the metrics endpoint.
|
||||
|
||||
Intentionally thread-unsafe for minimum overhead; worst case is a slightly
|
||||
off counter on concurrent writes which is fine for ops dashboards. Switch
|
||||
to a proper Prometheus client (``prometheus_client``) in P2.3 when needed.
|
||||
"""
|
||||
try:
|
||||
_metrics["routes"][route] += 1
|
||||
bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx"
|
||||
_metrics["statuses"][bucket] += 1
|
||||
samples = _metrics["latencies_ms"][route]
|
||||
samples.append(int(duration_ms))
|
||||
if len(samples) > _LATENCY_SAMPLE_CAP:
|
||||
del samples[0:len(samples) - _LATENCY_SAMPLE_CAP]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _convert_odoo_path(path: str) -> str:
|
||||
"""Translate Odoo's ``<int:foo>`` placeholder syntax to OpenAPI ``{foo}``."""
|
||||
def repl(m: re.Match) -> str:
|
||||
return "{" + m.group("name") + "}"
|
||||
return _PATH_PARAM_RE.sub(repl, path)
|
||||
|
||||
|
||||
def _path_parameters(path: str) -> list[dict]:
|
||||
params = []
|
||||
for m in _PATH_PARAM_RE.finditer(path):
|
||||
conv = (m.group("conv") or "string").lower()
|
||||
if conv in ("int", "integer"):
|
||||
schema = {"type": "integer"}
|
||||
elif conv in ("float", "number"):
|
||||
schema = {"type": "number"}
|
||||
else:
|
||||
schema = {"type": "string"}
|
||||
params.append({
|
||||
"name": m.group("name"),
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": schema,
|
||||
})
|
||||
return params
|
||||
|
||||
|
||||
def _iter_routes():
|
||||
"""Yield ``(route_str, method_name, tag, class_name, doc)`` for every
|
||||
``@http.route`` decorated callable currently registered.
|
||||
|
||||
Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the
|
||||
dispatcher actually routes to — including extension classes.
|
||||
"""
|
||||
seen = set()
|
||||
try:
|
||||
routing_map = request.env["ir.http"].routing_map()
|
||||
except Exception:
|
||||
_logger.exception("could not fetch routing_map")
|
||||
return
|
||||
|
||||
try:
|
||||
rules = list(routing_map.iter_rules())
|
||||
except Exception:
|
||||
_logger.exception("routing_map has no iter_rules")
|
||||
return
|
||||
|
||||
for rule in rules:
|
||||
url = rule.rule
|
||||
if not isinstance(url, str) or not url.startswith("/api/"):
|
||||
continue
|
||||
endpoint = rule.endpoint
|
||||
# ``endpoint`` is a wrapped callable; the real controller method is
|
||||
# stashed on ``endpoint.func`` by Odoo.
|
||||
fn = getattr(endpoint, "func", endpoint)
|
||||
routing = getattr(fn, "routing", {}) or {}
|
||||
methods = routing.get("methods") or list(rule.methods or {"GET"})
|
||||
methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}]
|
||||
if not methods:
|
||||
methods = ["GET"]
|
||||
cls_name = ""
|
||||
try:
|
||||
qual = getattr(fn, "__qualname__", fn.__name__)
|
||||
cls_name = qual.split(".")[0] if "." in qual else ""
|
||||
except Exception:
|
||||
cls_name = ""
|
||||
module = getattr(fn, "__module__", "") or ""
|
||||
tag_base = module.split(".")[-2] if "." in module else module or "api"
|
||||
doc = ""
|
||||
try:
|
||||
doc = (fn.__doc__ or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
for m in methods:
|
||||
key = (url, m.upper(), cls_name, fn.__name__)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield url, m.upper(), tag_base, cls_name, doc
|
||||
|
||||
|
||||
def _build_openapi_spec() -> dict:
|
||||
base_url = (
|
||||
request.env["ir.config_parameter"].sudo().get_param("web.base.url")
|
||||
or "http://localhost:8069"
|
||||
)
|
||||
paths: dict[str, dict] = {}
|
||||
tags: dict[str, str] = {}
|
||||
|
||||
for route, method, tag_base, cls_name, doc in _iter_routes():
|
||||
openapi_path = _convert_odoo_path(route)
|
||||
path_entry = paths.setdefault(openapi_path, {})
|
||||
|
||||
# Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``).
|
||||
segs = [s for s in route.split("/") if s and s != "api"]
|
||||
tag = segs[0] if segs else tag_base
|
||||
tags.setdefault(tag, f"Endpoints under /api/{tag}")
|
||||
|
||||
summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}"
|
||||
|
||||
op: dict = {
|
||||
"summary": summary,
|
||||
"operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}",
|
||||
"tags": [tag],
|
||||
"security": [{"BearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {"description": "OK",
|
||||
"content": {"application/json":
|
||||
{"schema": {"type": "object"}}}},
|
||||
"401": {"description": "Authentication required"},
|
||||
"400": {"description": "Bad request"},
|
||||
"500": {"description": "Server error"},
|
||||
},
|
||||
}
|
||||
params = _path_parameters(route)
|
||||
if params:
|
||||
op["parameters"] = params
|
||||
if method in ("POST", "PUT", "PATCH"):
|
||||
op["requestBody"] = {
|
||||
"required": False,
|
||||
"content": {"application/json":
|
||||
{"schema": {"type": "object"}}},
|
||||
}
|
||||
path_entry[method.lower()] = op
|
||||
|
||||
return {
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "EnCoach Platform API",
|
||||
"version": "19.0.1.0",
|
||||
"description": (
|
||||
"Auto-generated from ``@http.route`` decorators. "
|
||||
"Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the "
|
||||
"roadmap to enrich request/response shapes."
|
||||
),
|
||||
},
|
||||
"servers": [{"url": base_url}],
|
||||
"tags": [{"name": name, "description": desc}
|
||||
for name, desc in sorted(tags.items())],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {
|
||||
"type": "http", "scheme": "bearer", "bearerFormat": "JWT",
|
||||
},
|
||||
},
|
||||
},
|
||||
"paths": dict(sorted(paths.items())),
|
||||
}
|
||||
|
||||
|
||||
class EncoachOpenAPIController(Controller):
|
||||
|
||||
@http.route("/api/openapi.json", type="http", auth="none",
|
||||
methods=["GET"], csrf=False)
|
||||
def openapi(self, **_kw):
|
||||
"""Return a freshly generated OpenAPI 3.0 spec for the running server."""
|
||||
try:
|
||||
spec = _build_openapi_spec()
|
||||
return request.make_json_response(spec)
|
||||
except Exception:
|
||||
_logger.exception("openapi generation failed")
|
||||
return request.make_json_response(
|
||||
{"error": "could not generate openapi spec"}, status=500,
|
||||
)
|
||||
|
||||
@http.route("/api/metrics", type="http", auth="none",
|
||||
methods=["GET"], csrf=False)
|
||||
def metrics(self, **_kw):
|
||||
"""Return in-process request counters in a Prometheus-compatible text format.
|
||||
|
||||
This is a minimalist shim, not a full Prometheus client. It exposes:
|
||||
|
||||
- ``encoach_requests_total{route, method, status_bucket}`` — request count
|
||||
- ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based)
|
||||
- ``encoach_uptime_seconds`` — process uptime
|
||||
"""
|
||||
lines = [
|
||||
"# HELP encoach_uptime_seconds Seconds since this worker booted",
|
||||
"# TYPE encoach_uptime_seconds gauge",
|
||||
f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}",
|
||||
"",
|
||||
"# HELP encoach_requests_total Total number of /api/* requests handled",
|
||||
"# TYPE encoach_requests_total counter",
|
||||
]
|
||||
for route, count in _metrics["routes"].most_common():
|
||||
safe = route.replace('"', '\\"')
|
||||
lines.append(f'encoach_requests_total{{route="{safe}"}} {count}')
|
||||
lines.append("")
|
||||
lines.append("# HELP encoach_responses_total Count of responses per status bucket")
|
||||
lines.append("# TYPE encoach_responses_total counter")
|
||||
for bucket, count in _metrics["statuses"].items():
|
||||
lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}')
|
||||
lines.append("")
|
||||
lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route")
|
||||
lines.append("# TYPE encoach_request_latency_ms_p95 gauge")
|
||||
for route, samples in _metrics["latencies_ms"].items():
|
||||
if not samples:
|
||||
continue
|
||||
sorted_samples = sorted(samples)
|
||||
p95 = sorted_samples[min(len(sorted_samples) - 1,
|
||||
int(0.95 * len(sorted_samples)))]
|
||||
safe = route.replace('"', '\\"')
|
||||
lines.append(
|
||||
f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}'
|
||||
)
|
||||
body = "\n".join(lines) + "\n"
|
||||
return Response(body, status=200,
|
||||
content_type="text/plain; version=0.0.4")
|
||||
12
backend/custom_addons/encoach_api/data/cron.xml
Normal file
12
backend/custom_addons/encoach_api/data/cron.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="ir_cron_purge_expired_jwt" model="ir.cron">
|
||||
<field name="name">EnCoach: purge expired JWT refresh tokens</field>
|
||||
<field name="model_id" ref="model_encoach_jwt_token"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.purge_expired()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
2
backend/custom_addons/encoach_api/models/__init__.py
Normal file
2
backend/custom_addons/encoach_api/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import jwt_token
|
||||
from . import gdpr_erasure
|
||||
35
backend/custom_addons/encoach_api/models/gdpr_erasure.py
Normal file
35
backend/custom_addons/encoach_api/models/gdpr_erasure.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Tombstone record written when a user exercises the right-to-erasure.
|
||||
|
||||
We cannot hard-delete the original res.users row (ORM FK constraints on
|
||||
attempts, tickets, audit trail, etc.), so this table lets us prove the
|
||||
request happened, what was anonymised/deleted, and when.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class EncoachGdprErasureRequest(models.Model):
|
||||
_name = "encoach.gdpr.erasure.request"
|
||||
_description = "GDPR right-to-erasure audit trail"
|
||||
_order = "create_date desc"
|
||||
|
||||
user_login = fields.Char(required=True, index=True)
|
||||
user_email = fields.Char(index=True)
|
||||
user_id_archived = fields.Integer(
|
||||
index=True,
|
||||
help="Id of the res.users row at time of erasure (row is now archived).",
|
||||
)
|
||||
status = fields.Selection(
|
||||
[
|
||||
("requested", "Requested"),
|
||||
("completed", "Completed"),
|
||||
("partial", "Partial — manual follow-up required"),
|
||||
],
|
||||
default="requested", required=True,
|
||||
)
|
||||
summary = fields.Text(
|
||||
help="JSON summary of what was deleted/anonymised/retained.",
|
||||
)
|
||||
requested_at = fields.Datetime(default=fields.Datetime.now)
|
||||
completed_at = fields.Datetime()
|
||||
notes = fields.Text()
|
||||
105
backend/custom_addons/encoach_api/models/jwt_token.py
Normal file
105
backend/custom_addons/encoach_api/models/jwt_token.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Refresh-token ledger for the EnCoach API.
|
||||
|
||||
Access tokens stay short-lived (1 hour) and stateless — they are verified
|
||||
with the JWT signature alone. Refresh tokens, on the other hand, must be
|
||||
revocable so compromised devices can be signed out without rotating the
|
||||
server secret. Every refresh token corresponds to one row here, keyed by a
|
||||
UUID (``jti``) that the client echoes back on ``/api/auth/refresh``.
|
||||
|
||||
Rotation policy: ``/api/auth/refresh`` consumes the presented row (revokes
|
||||
it) and issues a brand-new row, so a stolen refresh token only works once.
|
||||
Replay attempts after rotation will be rejected because the replayed ``jti``
|
||||
is no longer ``active``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncoachJwtToken(models.Model):
|
||||
_name = "encoach.jwt.token"
|
||||
_description = "JWT Refresh Token Ledger"
|
||||
_order = "issued_at desc"
|
||||
_rec_name = "jti"
|
||||
|
||||
jti = fields.Char(
|
||||
string="Token ID",
|
||||
required=True,
|
||||
index=True,
|
||||
help="UUID used as the JWT's `jti` claim; serves as the primary handle.",
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
string="User",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
)
|
||||
issued_at = fields.Datetime(required=True, default=fields.Datetime.now)
|
||||
expires_at = fields.Datetime(required=True)
|
||||
last_used_at = fields.Datetime()
|
||||
revoked = fields.Boolean(default=False, index=True)
|
||||
user_agent = fields.Char(help="User-Agent header of the issuing client, for audit.")
|
||||
remote_ip = fields.Char(help="Remote IP of the issuing client, for audit.")
|
||||
|
||||
@api.model
|
||||
def _auto_init(self):
|
||||
res = super()._auto_init()
|
||||
cr = self.env.cr
|
||||
for name, ddl in (
|
||||
(
|
||||
"encoach_jwt_token_jti_unique",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS encoach_jwt_token_jti_unique "
|
||||
"ON encoach_jwt_token (jti)",
|
||||
),
|
||||
(
|
||||
"encoach_jwt_token_user_revoked_idx",
|
||||
"CREATE INDEX IF NOT EXISTS encoach_jwt_token_user_revoked_idx "
|
||||
"ON encoach_jwt_token (user_id, revoked, expires_at)",
|
||||
),
|
||||
):
|
||||
try:
|
||||
cr.execute(ddl)
|
||||
except Exception:
|
||||
_logger.warning("could not create index %s", name, exc_info=True)
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def revoke_by_jti(self, jti: str) -> bool:
|
||||
"""Soft-revoke every row matching ``jti``.
|
||||
|
||||
Returns ``True`` iff at least one row was flipped. Called from
|
||||
``/api/auth/refresh`` (to consume the rotated token) and
|
||||
``/api/logout`` (to end the session).
|
||||
"""
|
||||
if not jti:
|
||||
return False
|
||||
rows = self.sudo().search([("jti", "=", jti), ("revoked", "=", False)])
|
||||
if not rows:
|
||||
return False
|
||||
rows.write({"revoked": True})
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def purge_expired(self, batch_size: int = 500) -> int:
|
||||
"""Remove rows that expired more than a day ago.
|
||||
|
||||
Wired to the daily cleanup cron in :file:`data/cron.xml`; manual
|
||||
invocations (e.g. migrations) should pass a larger ``batch_size``
|
||||
to avoid multi-pass overhead.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(days=1)
|
||||
rows = self.sudo().search(
|
||||
[("expires_at", "<", cutoff)],
|
||||
limit=batch_size,
|
||||
)
|
||||
n = len(rows)
|
||||
if n:
|
||||
rows.unlink()
|
||||
return n
|
||||
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_jwt_token_user,encoach.jwt.token user,model_encoach_jwt_token,base.group_user,1,0,0,0
|
||||
access_encoach_jwt_token_admin,encoach.jwt.token admin,model_encoach_jwt_token,base.group_system,1,1,1,1
|
||||
access_gdpr_erasure_admin,encoach.gdpr.erasure.request admin,model_encoach_gdpr_erasure_request,base.group_system,1,1,1,1
|
||||
|
1
backend/custom_addons/encoach_api/tests/__init__.py
Normal file
1
backend/custom_addons/encoach_api/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_health
|
||||
27
backend/custom_addons/encoach_api/tests/test_health.py
Normal file
27
backend/custom_addons/encoach_api/tests/test_health.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Smoke tests for the health + GDPR endpoints.
|
||||
|
||||
Run with:
|
||||
./odoo-bin -c odoo.conf --test-tags encoach_api --stop-after-init
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install", "encoach_api")
|
||||
class TestHealthEndpoints(HttpCase):
|
||||
def test_health_live_returns_200(self):
|
||||
"""``GET /api/health`` must always return 200 once Odoo has booted."""
|
||||
response = self.url_open("/api/health", timeout=10)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json() if hasattr(response, "json") else json.loads(response.text)
|
||||
self.assertTrue(body.get("ok") is True or body.get("status") in {"ok", "live"})
|
||||
|
||||
def test_health_ready_returns_2xx(self):
|
||||
"""``GET /api/health/ready`` is allowed to flap, but must not 5xx."""
|
||||
response = self.url_open("/api/health/ready", timeout=10)
|
||||
self.assertIn(
|
||||
response.status_code, (200, 503),
|
||||
f"unexpected status {response.status_code}: {response.text[:200]}",
|
||||
)
|
||||
1
backend/custom_addons/encoach_api/utils/__init__.py
Normal file
1
backend/custom_addons/encoach_api/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import cache # noqa: F401
|
||||
106
backend/custom_addons/encoach_api/utils/cache.py
Normal file
106
backend/custom_addons/encoach_api/utils/cache.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Zero-dependency in-process TTL cache for hot endpoints.
|
||||
|
||||
Designed for read-heavy, mostly-idempotent endpoints like the admin report
|
||||
pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``)
|
||||
and the AI narrative generator. A single LRU-ish dict per worker with a hard
|
||||
time bound is usually enough to absorb the dashboard refresh storm caused by
|
||||
an admin tabbing between pages.
|
||||
|
||||
**Not** a substitute for Redis — cross-worker consistency and bounded memory
|
||||
are out of scope. If ops ever needs those guarantees, swap the internal dict
|
||||
for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and
|
||||
``invalidate``) stays identical.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_cache: dict[str, tuple[float, Any]] = {}
|
||||
_lock = threading.Lock()
|
||||
_MAX_ENTRIES = 512
|
||||
|
||||
|
||||
def _make_key(namespace: str, args: tuple, kwargs: dict) -> str:
|
||||
payload = json.dumps([args, kwargs], sort_keys=True, default=str)
|
||||
digest = hashlib.md5(payload.encode("utf-8")).hexdigest()
|
||||
return f"{namespace}:{digest}"
|
||||
|
||||
|
||||
def get(key: str):
|
||||
entry = _cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
expiry, value = entry
|
||||
if expiry < time.time():
|
||||
with _lock:
|
||||
_cache.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def put(key: str, value, ttl_seconds: int) -> None:
|
||||
with _lock:
|
||||
if len(_cache) >= _MAX_ENTRIES:
|
||||
oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4]
|
||||
for k, _v in oldest:
|
||||
_cache.pop(k, None)
|
||||
_cache[key] = (time.time() + max(1, ttl_seconds), value)
|
||||
|
||||
|
||||
def invalidate(namespace: str | None = None) -> int:
|
||||
"""Drop every cached entry, or just entries under ``namespace``.
|
||||
|
||||
Returns the number of removed entries. Callers should invoke this from
|
||||
write endpoints that affect the cached read so subsequent reads observe
|
||||
the new state.
|
||||
"""
|
||||
removed = 0
|
||||
with _lock:
|
||||
if namespace is None:
|
||||
removed = len(_cache)
|
||||
_cache.clear()
|
||||
return removed
|
||||
prefix = f"{namespace}:"
|
||||
keys = [k for k in _cache if k.startswith(prefix)]
|
||||
for k in keys:
|
||||
_cache.pop(k, None)
|
||||
removed = len(keys)
|
||||
return removed
|
||||
|
||||
|
||||
def memoize_ttl(namespace: str, ttl_seconds: int = 30):
|
||||
"""Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key.
|
||||
|
||||
The key is derived from the namespace + a stable JSON dump of the args,
|
||||
so callers don't need to worry about mutable keyword order or unhashable
|
||||
defaults. JWT decorator should run *before* this one so unauthenticated
|
||||
traffic never hits the cache.
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
key = _make_key(namespace, args, kwargs)
|
||||
except Exception:
|
||||
return func(*args, **kwargs)
|
||||
cached = get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = func(*args, **kwargs)
|
||||
try:
|
||||
put(key, value, ttl_seconds)
|
||||
except Exception:
|
||||
_logger.debug("cache put failed for %s", namespace, exc_info=True)
|
||||
return value
|
||||
wrapper._encoach_cache_namespace = namespace
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'})
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
2
backend/custom_addons/encoach_lms_api/__init__.py
Normal file
2
backend/custom_addons/encoach_lms_api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
33
backend/custom_addons/encoach_lms_api/__manifest__.py
Normal file
33
backend/custom_addons/encoach_lms_api/__manifest__.py
Normal 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',
|
||||
}
|
||||
@@ -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
|
||||
364
backend/custom_addons/encoach_lms_api/controllers/academic.py
Normal file
364
backend/custom_addons/encoach_lms_api/controllers/academic.py
Normal 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)
|
||||
117
backend/custom_addons/encoach_lms_api/controllers/activities.py
Normal file
117
backend/custom_addons/encoach_lms_api/controllers/activities.py
Normal 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)
|
||||
314
backend/custom_addons/encoach_lms_api/controllers/admissions.py
Normal file
314
backend/custom_addons/encoach_lms_api/controllers/admissions.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
613
backend/custom_addons/encoach_lms_api/controllers/courseware.py
Normal file
613
backend/custom_addons/encoach_lms_api/controllers/courseware.py
Normal 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)
|
||||
161
backend/custom_addons/encoach_lms_api/controllers/facilities.py
Normal file
161
backend/custom_addons/encoach_lms_api/controllers/facilities.py
Normal 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)
|
||||
174
backend/custom_addons/encoach_lms_api/controllers/faq.py
Normal file
174
backend/custom_addons/encoach_lms_api/controllers/faq.py
Normal 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)
|
||||
260
backend/custom_addons/encoach_lms_api/controllers/fees.py
Normal file
260
backend/custom_addons/encoach_lms_api/controllers/fees.py
Normal 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)
|
||||
201
backend/custom_addons/encoach_lms_api/controllers/grades.py
Normal file
201
backend/custom_addons/encoach_lms_api/controllers/grades.py
Normal 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)
|
||||
@@ -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)
|
||||
106
backend/custom_addons/encoach_lms_api/controllers/lessons.py
Normal file
106
backend/custom_addons/encoach_lms_api/controllers/lessons.py
Normal 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)
|
||||
215
backend/custom_addons/encoach_lms_api/controllers/library.py
Normal file
215
backend/custom_addons/encoach_lms_api/controllers/library.py
Normal 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)
|
||||
854
backend/custom_addons/encoach_lms_api/controllers/lms_core.py
Normal file
854
backend/custom_addons/encoach_lms_api/controllers/lms_core.py
Normal 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)
|
||||
@@ -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)
|
||||
151
backend/custom_addons/encoach_lms_api/controllers/payments.py
Normal file
151
backend/custom_addons/encoach_lms_api/controllers/payments.py
Normal 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)
|
||||
351
backend/custom_addons/encoach_lms_api/controllers/paymob.py
Normal file
351
backend/custom_addons/encoach_lms_api/controllers/paymob.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Paymob checkout initiator + HMAC-verified webhook.
|
||||
|
||||
This module talks to the `Paymob Accept <https://accept.paymob.com/>`_ API:
|
||||
|
||||
1. ``POST /api/payments/paymob/checkout``
|
||||
Authenticates with the merchant API key, registers an order, and
|
||||
generates a payment key. Returns the ``payment_key`` + the hosted iframe
|
||||
URL the frontend should redirect the user to.
|
||||
|
||||
2. ``POST /api/payments/paymob/webhook``
|
||||
Verifies Paymob's HMAC-SHA512 signature against a concatenated subset
|
||||
of the transaction fields (per Paymob docs), marks the matching
|
||||
``encoach.paymob.order`` as ``paid`` or ``failed``, and idempotently
|
||||
no-ops on duplicate deliveries.
|
||||
|
||||
Configuration (via ``ir.config_parameter``):
|
||||
|
||||
* ``encoach.paymob.api_key`` — merchant API key
|
||||
* ``encoach.paymob.hmac_secret`` — HMAC secret (from the Paymob dashboard)
|
||||
* ``encoach.paymob.integration_id`` — default integration id (card)
|
||||
* ``encoach.paymob.iframe_id`` — hosted iframe id for redirect URL
|
||||
|
||||
All secrets are read at request time so operators can rotate them without
|
||||
restarting Odoo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import Response, request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
_json_response,
|
||||
jwt_required,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PAYMOB_BASE = "https://accept.paymob.com/api"
|
||||
|
||||
# Fields Paymob concatenates (in this exact order) to compute the HMAC we
|
||||
# receive on webhook callbacks. Source: Paymob Accept docs, Transaction
|
||||
# Processed Callback → "HMAC Calculation" section.
|
||||
HMAC_FIELDS = [
|
||||
"amount_cents",
|
||||
"created_at",
|
||||
"currency",
|
||||
"error_occured",
|
||||
"has_parent_transaction",
|
||||
"id",
|
||||
"integration_id",
|
||||
"is_3d_secure",
|
||||
"is_auth",
|
||||
"is_capture",
|
||||
"is_refunded",
|
||||
"is_standalone_payment",
|
||||
"is_voided",
|
||||
"order.id",
|
||||
"owner",
|
||||
"pending",
|
||||
"source_data.pan",
|
||||
"source_data.sub_type",
|
||||
"source_data.type",
|
||||
"success",
|
||||
]
|
||||
|
||||
|
||||
def _config(key: str, default: str = "") -> str:
|
||||
return (
|
||||
request.env["ir.config_parameter"].sudo().get_param(f"encoach.paymob.{key}", default)
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _paymob_post(path: str, body: dict, timeout: int = 15) -> dict:
|
||||
url = f"{PAYMOB_BASE}{path}"
|
||||
resp = requests.post(url, json=body, timeout=timeout)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"Paymob {path} → {resp.status_code}: {resp.text[:300]}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Paymob {path} returned non-JSON: {resp.text[:300]}") from exc
|
||||
|
||||
|
||||
def _get_nested(obj: dict, dotted: str) -> Any:
|
||||
"""Walk 'a.b.c' through nested dicts, returning '' on any miss."""
|
||||
cur: Any = obj
|
||||
for part in dotted.split("."):
|
||||
if not isinstance(cur, dict):
|
||||
return ""
|
||||
cur = cur.get(part, "")
|
||||
return cur if cur is not None else ""
|
||||
|
||||
|
||||
def _compute_hmac(obj: dict, secret: str) -> str:
|
||||
"""Reproduce Paymob's HMAC-SHA512 over the canonical field sequence."""
|
||||
parts = [str(_get_nested(obj, f)).lower() if isinstance(_get_nested(obj, f), bool)
|
||||
else str(_get_nested(obj, f))
|
||||
for f in HMAC_FIELDS]
|
||||
message = "".join(parts).encode("utf-8")
|
||||
return hmac.new(secret.encode("utf-8"), message, hashlib.sha512).hexdigest()
|
||||
|
||||
|
||||
class EncoachPaymobController(http.Controller):
|
||||
"""Checkout + webhook endpoints."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/payments/paymob/checkout
|
||||
#
|
||||
# Body: { amount_cents, currency, product_ref?, description?, integration_id? }
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/payments/paymob/checkout",
|
||||
type="http", auth="none", methods=["POST"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def checkout(self, **kw):
|
||||
try:
|
||||
api_key = _config("api_key")
|
||||
iframe_id = _config("iframe_id")
|
||||
default_integration = _config("integration_id")
|
||||
if not api_key or not iframe_id or not default_integration:
|
||||
return _error_response(
|
||||
"Paymob is not configured. "
|
||||
"Set encoach.paymob.api_key / hmac_secret / integration_id / iframe_id "
|
||||
"in System Parameters.",
|
||||
503,
|
||||
)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
try:
|
||||
amount_cents = int(body.get("amount_cents") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return _error_response("amount_cents must be an integer", 400)
|
||||
if amount_cents <= 0:
|
||||
return _error_response("amount_cents must be positive", 400)
|
||||
|
||||
currency = (body.get("currency") or "EGP").upper()
|
||||
description = body.get("description") or ""
|
||||
product_ref = body.get("product_ref") or ""
|
||||
integration_id = str(body.get("integration_id") or default_integration)
|
||||
|
||||
user = request.env.user
|
||||
partner = user.partner_id
|
||||
|
||||
# Create our own tracking row *before* calling Paymob so we never
|
||||
# lose orders to partial failures.
|
||||
order = request.env["encoach.paymob.order"].sudo().create({
|
||||
"reference": f"enc-{user.id}-{int(fields.Datetime.now().timestamp())}",
|
||||
"user_id": user.id,
|
||||
"partner_id": partner.id if partner else False,
|
||||
"amount_cents": amount_cents,
|
||||
"currency": currency,
|
||||
"description": description,
|
||||
"product_ref": product_ref,
|
||||
"integration_id": integration_id,
|
||||
"state": "draft",
|
||||
})
|
||||
|
||||
# Step 1 — authenticate with the merchant API key.
|
||||
auth = _paymob_post("/auth/tokens", {"api_key": api_key})
|
||||
auth_token = auth.get("token")
|
||||
if not auth_token:
|
||||
raise RuntimeError("Paymob auth returned no token")
|
||||
|
||||
# Step 2 — register the order with Paymob.
|
||||
order_payload = {
|
||||
"auth_token": auth_token,
|
||||
"delivery_needed": False,
|
||||
"amount_cents": amount_cents,
|
||||
"currency": currency,
|
||||
"items": [],
|
||||
"merchant_order_id": order.reference,
|
||||
}
|
||||
paymob_order = _paymob_post("/ecommerce/orders", order_payload)
|
||||
paymob_order_id = str(paymob_order.get("id") or "")
|
||||
if not paymob_order_id:
|
||||
raise RuntimeError("Paymob order registration returned no id")
|
||||
|
||||
# Step 3 — generate a payment key.
|
||||
billing = {
|
||||
"first_name": (user.name or "Guest").split(" ")[0][:50] or "Guest",
|
||||
"last_name": (
|
||||
" ".join((user.name or "User").split(" ")[1:])[:50] or "User"
|
||||
),
|
||||
"email": user.email or "no-reply@encoach.invalid",
|
||||
"phone_number": partner.phone or partner.mobile or "+201000000000",
|
||||
"apartment": "NA", "floor": "NA", "street": "NA",
|
||||
"building": "NA", "shipping_method": "NA", "postal_code": "NA",
|
||||
"city": "NA", "country": "EG", "state": "NA",
|
||||
}
|
||||
pk_payload = {
|
||||
"auth_token": auth_token,
|
||||
"amount_cents": amount_cents,
|
||||
"currency": currency,
|
||||
"order_id": paymob_order_id,
|
||||
"billing_data": billing,
|
||||
"integration_id": int(integration_id),
|
||||
"lock_order_when_paid": True,
|
||||
}
|
||||
pk = _paymob_post("/acceptance/payment_keys", pk_payload)
|
||||
payment_key = pk.get("token")
|
||||
if not payment_key:
|
||||
raise RuntimeError("Paymob payment_keys returned no token")
|
||||
|
||||
iframe_url = (
|
||||
f"https://accept.paymob.com/api/acceptance/iframes/"
|
||||
f"{iframe_id}?payment_token={payment_key}"
|
||||
)
|
||||
|
||||
order.write({
|
||||
"paymob_order_id": paymob_order_id,
|
||||
"payment_key": payment_key,
|
||||
"state": "initiated",
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
"order_id": order.id,
|
||||
"reference": order.reference,
|
||||
"paymob_order_id": paymob_order_id,
|
||||
"payment_key": payment_key,
|
||||
"iframe_url": iframe_url,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("paymob checkout failed")
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/payments/paymob/webhook
|
||||
#
|
||||
# Paymob delivers the full transaction JSON with an ``hmac`` query-string
|
||||
# parameter. We verify the HMAC over a canonical subset of fields and
|
||||
# then update the matching order.
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/payments/paymob/webhook",
|
||||
type="http", auth="public", methods=["POST"], csrf=False,
|
||||
)
|
||||
def webhook(self, **kw):
|
||||
try:
|
||||
secret = _config("hmac_secret")
|
||||
if not secret:
|
||||
_logger.error("paymob webhook: hmac secret not configured")
|
||||
return Response(status=503)
|
||||
|
||||
received_hmac = (kw.get("hmac") or "").strip()
|
||||
if not received_hmac:
|
||||
return Response(status=400)
|
||||
|
||||
raw = (request.httprequest.data or b"")
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8") or "{}")
|
||||
except Exception:
|
||||
return Response(status=400)
|
||||
|
||||
# Paymob's payload envelope is:
|
||||
# { "type": "TRANSACTION", "obj": { ...fields... } }
|
||||
inner = payload.get("obj") or payload
|
||||
|
||||
expected_hmac = _compute_hmac(inner, secret)
|
||||
if not hmac.compare_digest(expected_hmac, received_hmac):
|
||||
_logger.warning(
|
||||
"paymob webhook: HMAC mismatch (expected=%s… got=%s…)",
|
||||
expected_hmac[:16], received_hmac[:16],
|
||||
)
|
||||
return Response(status=401)
|
||||
|
||||
merchant_order_ref = (
|
||||
_get_nested(inner, "order.merchant_order_id")
|
||||
or inner.get("merchant_order_id")
|
||||
or ""
|
||||
)
|
||||
paymob_order_id = str(_get_nested(inner, "order.id") or "")
|
||||
success = bool(inner.get("success"))
|
||||
|
||||
Order = request.env["encoach.paymob.order"].sudo()
|
||||
order = False
|
||||
if merchant_order_ref:
|
||||
order = Order.search([("reference", "=", merchant_order_ref)], limit=1)
|
||||
if not order and paymob_order_id:
|
||||
order = Order.search([("paymob_order_id", "=", paymob_order_id)], limit=1)
|
||||
if not order:
|
||||
_logger.warning(
|
||||
"paymob webhook: no matching order for ref=%s paymob=%s",
|
||||
merchant_order_ref, paymob_order_id,
|
||||
)
|
||||
# Return 200 so Paymob doesn't retry indefinitely — the event
|
||||
# will still be visible in their dashboard.
|
||||
return Response(status=200)
|
||||
|
||||
if order.state == "paid":
|
||||
# Idempotent: duplicate delivery.
|
||||
return Response(status=200)
|
||||
|
||||
payload_json = json.dumps(inner)
|
||||
if success:
|
||||
order.mark_paid(payload_json, received_hmac)
|
||||
else:
|
||||
order.mark_failed(payload_json, received_hmac)
|
||||
|
||||
return Response(status=200)
|
||||
except Exception:
|
||||
_logger.exception("paymob webhook handler crashed")
|
||||
return Response(status=500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/payments/paymob/orders — list my orders
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/payments/paymob/orders",
|
||||
type="http", auth="none", methods=["GET"], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def my_orders(self, **kw):
|
||||
try:
|
||||
Order = request.env["encoach.paymob.order"].sudo()
|
||||
domain = [("user_id", "=", request.env.user.id)]
|
||||
rows = Order.search(domain, order="create_date desc", limit=200)
|
||||
items = [{
|
||||
"id": r.id,
|
||||
"reference": r.reference,
|
||||
"paymob_order_id": r.paymob_order_id or "",
|
||||
"amount_cents": r.amount_cents,
|
||||
"currency": r.currency,
|
||||
"description": r.description or "",
|
||||
"product_ref": r.product_ref or "",
|
||||
"state": r.state,
|
||||
"create_date": fields.Datetime.to_string(r.create_date) if r.create_date else None,
|
||||
"last_event_at": (
|
||||
fields.Datetime.to_string(r.last_event_at) if r.last_event_at else None
|
||||
),
|
||||
} for r in rows]
|
||||
return _json_response({
|
||||
"items": items,
|
||||
"data": items,
|
||||
"total": len(items),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("paymob my_orders failed")
|
||||
return _error_response(str(e), 500)
|
||||
@@ -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)
|
||||
489
backend/custom_addons/encoach_lms_api/controllers/reports.py
Normal file
489
backend/custom_addons/encoach_lms_api/controllers/reports.py
Normal 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)
|
||||
286
backend/custom_addons/encoach_lms_api/controllers/resources.py
Normal file
286
backend/custom_addons/encoach_lms_api/controllers/resources.py
Normal 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)
|
||||
112
backend/custom_addons/encoach_lms_api/controllers/stats.py
Normal file
112
backend/custom_addons/encoach_lms_api/controllers/stats.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
176
backend/custom_addons/encoach_lms_api/controllers/tickets.py
Normal file
176
backend/custom_addons/encoach_lms_api/controllers/tickets.py
Normal 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)
|
||||
@@ -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)
|
||||
372
backend/custom_addons/encoach_lms_api/controllers/training.py
Normal file
372
backend/custom_addons/encoach_lms_api/controllers/training.py
Normal 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)
|
||||
542
backend/custom_addons/encoach_lms_api/controllers/users_roles.py
Normal file
542
backend/custom_addons/encoach_lms_api/controllers/users_roles.py
Normal 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)
|
||||
12
backend/custom_addons/encoach_lms_api/models/__init__.py
Normal file
12
backend/custom_addons/encoach_lms_api/models/__init__.py
Normal 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
|
||||
13
backend/custom_addons/encoach_lms_api/models/asset.py
Normal file
13
backend/custom_addons/encoach_lms_api/models/asset.py
Normal 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)
|
||||
@@ -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)
|
||||
56
backend/custom_addons/encoach_lms_api/models/course_ext.py
Normal file
56
backend/custom_addons/encoach_lms_api/models/course_ext.py
Normal 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'))
|
||||
235
backend/custom_addons/encoach_lms_api/models/courseware.py
Normal file
235
backend/custom_addons/encoach_lms_api/models/courseware.py
Normal 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)
|
||||
43
backend/custom_addons/encoach_lms_api/models/faq.py
Normal file
43
backend/custom_addons/encoach_lms_api/models/faq.py
Normal 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.')
|
||||
26
backend/custom_addons/encoach_lms_api/models/gradebook.py
Normal file
26
backend/custom_addons/encoach_lms_api/models/gradebook.py
Normal 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')
|
||||
24
backend/custom_addons/encoach_lms_api/models/lesson.py
Normal file
24
backend/custom_addons/encoach_lms_api/models/lesson.py
Normal 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')
|
||||
71
backend/custom_addons/encoach_lms_api/models/notification.py
Normal file
71
backend/custom_addons/encoach_lms_api/models/notification.py
Normal 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
Reference in New Issue
Block a user