Compare commits
72 Commits
full_stack
...
0d7139cbc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7139cbc8 | ||
|
|
565ddd5ff7 | ||
|
|
fc384efe85 | ||
| a6fa9d78d8 | |||
| f37c8fc7f7 | |||
| de89518280 | |||
| fbf6150cca | |||
| a4ec9a5fb2 | |||
| 4e4202742f | |||
| 499f58f617 | |||
| 65d5eb2480 | |||
|
|
5ec6ae0ae1 | ||
|
|
cd47d01f53 | ||
|
|
096b042daf | ||
|
|
afd1662a60 | ||
|
|
0ed7f88cab | ||
|
|
cfdf2be527 | ||
|
|
ed8e75d88c | ||
|
|
1dd1168fee | ||
|
|
971e9860c8 | ||
|
|
8d173b93cb | ||
| a5a3a2dc62 | |||
|
|
fa6f4976c3 | ||
|
|
882179870c | ||
|
|
e2aa8031ff | ||
|
|
1223074bde | ||
|
|
75ee0f1fe0 | ||
|
|
170d7c8d2e | ||
|
|
d34180e107 | ||
|
|
eef3edf7e8 | ||
|
|
d35ccc255f | ||
|
|
a554ef5d42 | ||
| b1b3d20eb4 | |||
|
|
4253f0174a | ||
|
|
bab588b9da | ||
| e33a9a61bb | |||
|
|
93def02e94 | ||
|
|
e1f059069f | ||
|
|
6712d1d551 | ||
| 7024197c7b | |||
|
|
93c530eef2 | ||
|
|
e70a2854f4 | ||
|
|
3972023a30 | ||
|
|
dcf5ea6941 | ||
|
|
47d09a3ce5 | ||
|
|
1a0349c381 | ||
|
|
c016a52200 | ||
|
|
96f419d653 | ||
|
|
d940db075e | ||
|
|
7f23127e44 | ||
|
|
7737f6def5 | ||
|
|
7f1f058e8f | ||
|
|
6ec68160c8 | ||
|
|
98b9837a54 | ||
|
|
74d83af57f | ||
|
|
50f58dc995 | ||
|
|
b9df9b5299 | ||
|
|
372b835e84 | ||
|
|
01cce7662d | ||
|
|
2b2e81514b | ||
|
|
ca91544acd | ||
|
|
82ec3debcc | ||
|
|
571a08d0f7 | ||
|
|
6a62a43d61 | ||
|
|
0c8443256d | ||
|
|
907a5c0e92 | ||
| bdc6598734 | |||
| d9f8a62886 | |||
| 66ce923907 | |||
| c1b23c8a5c | |||
| 9b6a2b7c22 | |||
|
|
3e83d8d7d5 |
67
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Deploy Backend to Staging
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy backend to staging
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Pull latest code
|
||||||
|
run: |
|
||||||
|
cd /opt/encoach/encoach_backend_new_v2
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/main
|
||||||
|
echo "Deployed: $(git log -1 --oneline)"
|
||||||
|
|
||||||
|
- name: Build Odoo image
|
||||||
|
run: |
|
||||||
|
cd /opt/encoach/encoach_backend_new_v2
|
||||||
|
# odoo.conf is not in the repo; copy from the persistent override for the build context
|
||||||
|
cp /opt/encoach/overrides/odoo.conf ./odoo.conf
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f /opt/encoach/overrides/encoach.override.yml \
|
||||||
|
build odoo
|
||||||
|
rm -f ./odoo.conf
|
||||||
|
|
||||||
|
- name: Run DB migrations
|
||||||
|
run: |
|
||||||
|
# Dynamically fetch the list of installed encoach_* modules from the DB
|
||||||
|
MODULES=$(docker exec encoach-v4-db psql -U odoo -d encoach_v2 -tAc \
|
||||||
|
"SELECT string_agg(name, ,) FROM ir_module_module WHERE state=installed AND name LIKE encoach%;")
|
||||||
|
echo "Upgrading modules: $MODULES"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--network encoach_backend_new_v2_default \
|
||||||
|
-v /opt/encoach/overrides/odoo.conf:/etc/odoo/odoo.conf:ro \
|
||||||
|
-v /opt/encoach/encoach_backend_new_v2:/mnt/extra-addons:ro \
|
||||||
|
-v /opt/encoach/encoach_backend_new_v2/openeducat_erp-19.0/openeducat_erp-19.0:/mnt/extra-addons/openeducat_erp-19.0:ro \
|
||||||
|
-v encoach_backend_new_v2_odoo-web-data:/var/lib/odoo \
|
||||||
|
encoach-backend:latest \
|
||||||
|
odoo -u "$MODULES" --stop-after-init 2>&1 | tail -20
|
||||||
|
|
||||||
|
- name: Restart Odoo
|
||||||
|
run: |
|
||||||
|
cd /opt/encoach/encoach_backend_new_v2
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f /opt/encoach/overrides/encoach.override.yml \
|
||||||
|
up -d --no-deps odoo
|
||||||
|
|
||||||
|
- name: Smoke test
|
||||||
|
run: |
|
||||||
|
echo "Polling Odoo /api/health (max 180s)..."
|
||||||
|
STATUS="000"
|
||||||
|
for i in $(seq 1 18); do
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://localhost:8069/api/health 2>/dev/null || echo "000")
|
||||||
|
[ "$STATUS" = "200" ] && echo "Odoo healthy after $((i*10))s" && break
|
||||||
|
echo " attempt $i: $STATUS — waiting 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
[ "$STATUS" != "200" ] && echo "ERROR: Odoo still $STATUS after 180s" && exit 1
|
||||||
|
FE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://localhost:3000/ 2>/dev/null || echo "000")
|
||||||
|
[ "$FE" != "200" ] && echo "ERROR: Frontend $FE" && exit 1
|
||||||
|
echo "All OK — Odoo=200 Frontend=$FE"
|
||||||
66
.gitignore
vendored
@@ -1,66 +0,0 @@
|
|||||||
# Environment files — never commit secrets
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.pyo
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
env/
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Poetry
|
|
||||||
poetry.lock
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
*.tar
|
|
||||||
|
|
||||||
# Frontend repo (separate repository)
|
|
||||||
new_project/encoach_frontend_new_v1/
|
|
||||||
|
|
||||||
# Node modules
|
|
||||||
node_modules/
|
|
||||||
frontend/node_modules/
|
|
||||||
|
|
||||||
# Local dev artifacts
|
|
||||||
miniconda3/
|
|
||||||
pgdata/
|
|
||||||
.conda-envs/
|
|
||||||
.conda-pkgs/
|
|
||||||
|
|
||||||
# Odoo core source (cloned separately)
|
|
||||||
odoo/
|
|
||||||
|
|
||||||
# Local Odoo config and data
|
|
||||||
odoo.conf
|
|
||||||
/data/
|
|
||||||
|
|
||||||
# Enterprise / extra addons (not part of this repo)
|
|
||||||
addons_enterprise/
|
|
||||||
addons_extra/
|
|
||||||
new_project/enterprise-17/
|
|
||||||
|
|
||||||
# Third-party modules (downloaded separately)
|
|
||||||
new_project/openeducat_erp-19.0/
|
|
||||||
backend/openeducat_erp-19.0/
|
|
||||||
new_project/openeducat_erp-19.0.zip
|
|
||||||
new_project/openeducate_enterprise-17.zip
|
|
||||||
new_project/encoach_frontend_new_v1-main.zip
|
|
||||||
|
|
||||||
# Local tools
|
|
||||||
new_project/.tools/
|
|
||||||
|
|
||||||
# Large binary archives
|
|
||||||
*.zip
|
|
||||||
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM odoo:19.0
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip3 install --break-system-packages --no-cache-dir --ignore-installed typing_extensions -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
COPY custom_addons /opt/odoo/custom_addons
|
||||||
|
COPY odoo.conf /etc/odoo/odoo.conf
|
||||||
|
|
||||||
|
USER odoo
|
||||||
|
|
||||||
|
EXPOSE 8069 8072
|
||||||
BIN
GE1 Course Outline_ Fall AY25-26.pdf
Normal file
32
README.md
@@ -1,32 +0,0 @@
|
|||||||
# EnCoach Backend — v2
|
|
||||||
|
|
||||||
## Branching Workflow
|
|
||||||
|
|
||||||
This repo is connected to the staging server via a Git post-receive hook.
|
|
||||||
**All deployment is automatic — but only after code review approval.**
|
|
||||||
|
|
||||||
### How to contribute
|
|
||||||
|
|
||||||
1. Never push directly to `main` — branch protection will block it.
|
|
||||||
2. Create a feature or fix branch:
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
```
|
|
||||||
3. Develop, commit, and push your branch:
|
|
||||||
```bash
|
|
||||||
git push origin feature/your-feature-name
|
|
||||||
```
|
|
||||||
4. Open a **Pull Request** on Gitea targeting `main`.
|
|
||||||
5. Request review from **devops (Talal)**.
|
|
||||||
6. Once approved and merged, the staging server rebuilds and redeploys automatically.
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
|
|
||||||
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`.
|
|
||||||
Contact the team lead if you need a local copy of the environment variables.
|
|
||||||
|
|
||||||
### Required files (push with your code)
|
|
||||||
|
|
||||||
- `Dockerfile` — used by the staging server to build the container image
|
|
||||||
- `docker-compose.yml` — defines the backend service (port mapping, env vars, etc.)
|
|
||||||
# Pipeline test Mon Mar 30 19:42:50 +04 2026
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from . import ai_controller
|
|
||||||
from . import coach_controller
|
|
||||||
from . import media_controller
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
"""REST endpoints for AI services — matches frontend service calls."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from odoo import http
|
|
||||||
from odoo.http import request, Response
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_response(data, status=200):
|
|
||||||
return Response(
|
|
||||||
json.dumps(data, default=str),
|
|
||||||
status=status,
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_json():
|
|
||||||
try:
|
|
||||||
return json.loads(request.httprequest.data or "{}")
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class AIController(http.Controller):
|
|
||||||
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
|
||||||
|
|
||||||
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
|
|
||||||
@http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_search(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
query = body.get("query", "")
|
|
||||||
if not query:
|
|
||||||
return _json_response({"answer": "", "suggestions": []})
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
result = ai.search_with_rag(query, context=body.get("context", ""))
|
|
||||||
return _json_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("AI search failed")
|
|
||||||
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
|
|
||||||
|
|
||||||
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
|
|
||||||
@http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False)
|
|
||||||
def ai_vector_search(self, **kw):
|
|
||||||
query = request.params.get("q", "")
|
|
||||||
content_type = request.params.get("content_type")
|
|
||||||
limit = min(int(request.params.get("limit", "10")), 50)
|
|
||||||
if not query:
|
|
||||||
return _json_response({"results": [], "query": ""})
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
|
|
||||||
svc = EmbeddingService(request.env)
|
|
||||||
results = svc.search(query, content_type=content_type, limit=limit)
|
|
||||||
return _json_response({"results": results, "query": query, "count": len(results)})
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("Vector search failed")
|
|
||||||
return _json_response({"results": [], "query": query, "error": str(e)})
|
|
||||||
|
|
||||||
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
|
|
||||||
@http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_insights(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
result = ai.generate_insights(
|
|
||||||
body.get("data", {}),
|
|
||||||
insight_type=body.get("type", "general"),
|
|
||||||
)
|
|
||||||
return _json_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("AI insights failed")
|
|
||||||
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
|
|
||||||
|
|
||||||
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
|
|
||||||
@http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False)
|
|
||||||
def ai_alerts(self, **kw):
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
context = request.params.get("context", "dashboard")
|
|
||||||
result = ai.generate_insights(
|
|
||||||
{"context": context, "request": "alerts"},
|
|
||||||
insight_type="alerts",
|
|
||||||
)
|
|
||||||
alerts = result.get("insights", [])
|
|
||||||
return _json_response({"alerts": alerts})
|
|
||||||
except Exception:
|
|
||||||
return _json_response({"alerts": []})
|
|
||||||
|
|
||||||
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
|
|
||||||
@http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_report_narrative(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
narrative = ai.generate_report_narrative(
|
|
||||||
body.get("report_type", "performance"),
|
|
||||||
body.get("data", {}),
|
|
||||||
)
|
|
||||||
return _json_response({"narrative": narrative})
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"narrative": f"Report generation unavailable: {e}"})
|
|
||||||
|
|
||||||
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
|
|
||||||
@http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_batch_optimize(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
result = ai.batch_optimize(
|
|
||||||
body.get("items", []),
|
|
||||||
optimization_type=body.get("type", "schedule"),
|
|
||||||
)
|
|
||||||
return _json_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
|
|
||||||
|
|
||||||
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
|
|
||||||
@http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_grade_suggest(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
skill = body.get("skill", "writing")
|
|
||||||
if skill == "speaking":
|
|
||||||
result = ai.grade_speaking(
|
|
||||||
body.get("rubric", "IELTS Speaking Band Descriptors"),
|
|
||||||
body.get("submission_text", ""),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = ai.grade_writing(
|
|
||||||
body.get("rubric", "IELTS Writing Band Descriptors"),
|
|
||||||
body.get("task", ""),
|
|
||||||
body.get("submission_text", ""),
|
|
||||||
)
|
|
||||||
return _json_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("AI grade suggest failed")
|
|
||||||
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
|
|
||||||
|
|
||||||
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
|
|
||||||
@http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_generate_resource(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
result = ai.generate_content_dedup(
|
|
||||||
body.get("content_type", "reading_passage"),
|
|
||||||
body.get("brief", {}),
|
|
||||||
cefr_level=body.get("cefr_level", "B2"),
|
|
||||||
)
|
|
||||||
return _json_response({"resource": result, "status": "generated"})
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"resource": None, "status": "error", "error": str(e)})
|
|
||||||
|
|
||||||
# ── POST /api/ai/detect — GPTZero AI detection ──
|
|
||||||
@http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_detect(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
|
|
||||||
svc = GPTZeroService(request.env)
|
|
||||||
result = svc.detect(body.get("text", ""))
|
|
||||||
return _json_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
|
|
||||||
|
|
||||||
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
|
|
||||||
@http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def plagiarism_check(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
|
|
||||||
svc = GPTZeroService(request.env)
|
|
||||||
result = svc.detect(body.get("text", ""))
|
|
||||||
report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}"
|
|
||||||
return _json_response({"report_id": report_id, **result})
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"report_id": None, "error": str(e)})
|
|
||||||
|
|
||||||
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
|
|
||||||
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_suggest_topics(self, domain_id, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
|
|
||||||
"Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}"
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": json.dumps({"domain_id": domain_id, **body})},
|
|
||||||
]
|
|
||||||
result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest")
|
|
||||||
return _json_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"topics": [], "error": str(e)})
|
|
||||||
|
|
||||||
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
|
|
||||||
@http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def learning_plan_generate(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
"Create a personalized learning plan. Return JSON: "
|
|
||||||
"{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": "
|
|
||||||
"[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, "
|
|
||||||
"\"recommendations\": [string]}"
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": json.dumps(body)},
|
|
||||||
]
|
|
||||||
result = ai.chat_json(messages, action="learning_plan")
|
|
||||||
return _json_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"plan": None, "error": str(e)})
|
|
||||||
|
|
||||||
# ── Workbench endpoints — AiWorkbench.tsx ──
|
|
||||||
@http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def workbench_outline(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
"Generate a course outline. Return JSON: {\"chapters\": "
|
|
||||||
"[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}"
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": json.dumps(body)},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action="workbench_outline"))
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"chapters": [], "error": str(e)})
|
|
||||||
|
|
||||||
@http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def workbench_chapter(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
"Generate detailed chapter content for a course. Return JSON: "
|
|
||||||
"{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], "
|
|
||||||
"\"key_vocabulary\": [string]}"
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": json.dumps(body)},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096))
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"content": "", "error": str(e)})
|
|
||||||
|
|
||||||
@http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def workbench_rubric(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
|
||||||
"{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": "
|
|
||||||
"[{\"score\": number, \"description\": string}]}]}}"
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": json.dumps(body)},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action="workbench_rubric"))
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"rubric": None, "error": str(e)})
|
|
||||||
|
|
||||||
@http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def workbench_regenerate(self, **kw):
|
|
||||||
return self.workbench_chapter(**kw)
|
|
||||||
|
|
||||||
@http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def workbench_publish(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
Module = request.env.get("encoach.course.module")
|
|
||||||
if Module:
|
|
||||||
Module = Module.sudo()
|
|
||||||
chapters = body.get("chapters", [])
|
|
||||||
course_id = body.get("course_id")
|
|
||||||
created_ids = []
|
|
||||||
for i, ch in enumerate(chapters):
|
|
||||||
if isinstance(ch, dict):
|
|
||||||
vals = {
|
|
||||||
"name": ch.get("title", f"Module {i+1}"),
|
|
||||||
"sequence": i + 1,
|
|
||||||
}
|
|
||||||
if course_id:
|
|
||||||
vals["course_id"] = int(course_id)
|
|
||||||
rec = Module.create(vals)
|
|
||||||
created_ids.append(rec.id)
|
|
||||||
return _json_response({
|
|
||||||
"status": "published",
|
|
||||||
"module_ids": created_ids,
|
|
||||||
"count": len(created_ids),
|
|
||||||
})
|
|
||||||
return _json_response({"status": "published", "id": body.get("id")})
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("workbench publish failed")
|
|
||||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
|
||||||
|
|
||||||
# ── Exam generation — GenerationPage.tsx ──
|
|
||||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def exam_generate(self, module, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
|
|
||||||
if body.get("generate_passage"):
|
|
||||||
return self._generate_passage(ai, body)
|
|
||||||
if body.get("generate_instructions"):
|
|
||||||
return self._generate_writing_instructions(ai, body)
|
|
||||||
if body.get("generate_script"):
|
|
||||||
return self._generate_speaking_script(ai, body)
|
|
||||||
if body.get("generate_context"):
|
|
||||||
return self._generate_listening_context(ai, body)
|
|
||||||
if body.get("generate_exercises"):
|
|
||||||
return self._generate_exercises(ai, module, body)
|
|
||||||
|
|
||||||
difficulty = body.get("difficulty", "B2")
|
|
||||||
topic = body.get("topic", "")
|
|
||||||
count = body.get("count") or body.get("question_count") or 5
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
|
|
||||||
f"Return JSON: "
|
|
||||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
|
||||||
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"questions": [], "error": str(e)})
|
|
||||||
|
|
||||||
def _generate_passage(self, ai, body):
|
|
||||||
topic = body.get("topic", "general knowledge")
|
|
||||||
difficulty = body.get("difficulty", "B2")
|
|
||||||
word_count = body.get("word_count", 300)
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. "
|
|
||||||
"The passage should be suitable for an English language exam. "
|
|
||||||
'Return JSON: {"passage": "the full passage text", "title": "passage title"}'
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": f"Topic: {topic}"},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action="generate_passage"))
|
|
||||||
|
|
||||||
def _generate_writing_instructions(self, ai, body):
|
|
||||||
topic = body.get("topic", "general")
|
|
||||||
difficulty = body.get("difficulty", "A1")
|
|
||||||
task_type = body.get("task_type", "letter")
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. "
|
|
||||||
"Include clear instructions that tell the student what to write about. "
|
|
||||||
'Return JSON: {"instructions": "the full instructions text"}'
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": f"Topic: {topic}"},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action="generate_writing_instructions"))
|
|
||||||
|
|
||||||
def _generate_speaking_script(self, ai, body):
|
|
||||||
topics = body.get("topics", [])
|
|
||||||
difficulty = body.get("difficulty", "B1")
|
|
||||||
part = body.get("part", "speaking_1")
|
|
||||||
topic_str = ", ".join(t for t in topics if t) if topics else "general conversation"
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
f"Generate a speaking exam script for {part} at CEFR {difficulty} level. "
|
|
||||||
"Include examiner questions and prompts for the student. "
|
|
||||||
'Return JSON: {"script": "the full script text"}'
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": f"Topics: {topic_str}"},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action="generate_speaking_script"))
|
|
||||||
|
|
||||||
def _generate_listening_context(self, ai, body):
|
|
||||||
topic = body.get("topic", "everyday life")
|
|
||||||
section_type = body.get("section_type", "social_conversation")
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
f"Generate a listening section transcript for a {section_type.replace('_', ' ')} "
|
|
||||||
"in an English language exam. Include speaker labels. "
|
|
||||||
'Return JSON: {"context": "the full conversation/monologue transcript"}'
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": f"Topic: {topic}"},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action="generate_listening_context"))
|
|
||||||
|
|
||||||
def _generate_exercises(self, ai, module, body):
|
|
||||||
passage_text = body.get("passage_text", "")
|
|
||||||
exercise_types = body.get("exercise_types", [])
|
|
||||||
count = body.get("count_per_type", 5)
|
|
||||||
types_str = ", ".join(exercise_types) if exercise_types else "multiple choice"
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
f"Based on the following text, generate {count} exercises of these types: {types_str}. "
|
|
||||||
"Return JSON: "
|
|
||||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
|
||||||
'"correct_answer": string, "explanation": string, "marks": number}]}'
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": passage_text[:3000]},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
|
|
||||||
|
|
||||||
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
|
||||||
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def generation_submit(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
title = body.get("title", "").strip()
|
|
||||||
if not title:
|
|
||||||
return _json_response({"error": "title is required"}, 400)
|
|
||||||
|
|
||||||
label = body.get("label", "")
|
|
||||||
modules = body.get("modules", {})
|
|
||||||
skip_approval = body.get("skip_approval", False)
|
|
||||||
|
|
||||||
template_id = False
|
|
||||||
try:
|
|
||||||
Template = request.env["encoach.exam.template"]
|
|
||||||
template = Template.sudo().create({
|
|
||||||
"name": title,
|
|
||||||
"code": label,
|
|
||||||
"type": "custom",
|
|
||||||
"editable": True,
|
|
||||||
"teacher_id": request.env.user.id,
|
|
||||||
"results_release_mode": "auto",
|
|
||||||
})
|
|
||||||
template_id = template.id
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
Exam = request.env["encoach.exam.custom"]
|
|
||||||
except KeyError:
|
|
||||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
|
||||||
|
|
||||||
exam = Exam.sudo().create({
|
|
||||||
"title": title,
|
|
||||||
"teacher_id": request.env.user.id,
|
|
||||||
"template_id": template_id,
|
|
||||||
"status": "published" if skip_approval else "draft",
|
|
||||||
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
|
|
||||||
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
Section = request.env["encoach.exam.custom.section"]
|
|
||||||
seq = 10
|
|
||||||
for mod_key, mod_data in modules.items():
|
|
||||||
Section.sudo().create({
|
|
||||||
"exam_id": exam.id,
|
|
||||||
"title": mod_key.capitalize(),
|
|
||||||
"skill": mod_key,
|
|
||||||
"time_limit_min": mod_data.get("timer", 0),
|
|
||||||
"scoring_method": "auto",
|
|
||||||
"sequence": seq,
|
|
||||||
})
|
|
||||||
seq += 10
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return _json_response({
|
|
||||||
"exam_id": exam.id,
|
|
||||||
"status": exam.status,
|
|
||||||
"template_id": template_id,
|
|
||||||
}, 201)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("generation submit failed")
|
|
||||||
return _json_response({"error": str(e)}, 500)
|
|
||||||
|
|
||||||
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
|
|
||||||
@http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def ai_batch_optimize_apply(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
optimized = body.get("optimized", [])
|
|
||||||
batch_id = body.get("batch_id")
|
|
||||||
applied = 0
|
|
||||||
try:
|
|
||||||
for item in optimized:
|
|
||||||
if isinstance(item, dict) and item.get("id"):
|
|
||||||
applied += 1
|
|
||||||
return _json_response({"applied": applied, "batch_id": batch_id})
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
|
||||||
|
|
||||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
|
||||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def exam_generate_save(self, module, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
questions = body.get("questions", [])
|
|
||||||
saved = 0
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
Question = request.env["encoach.question"].sudo()
|
|
||||||
for q in questions:
|
|
||||||
if isinstance(q, dict):
|
|
||||||
q_type = q.get("type", "mcq").lower().replace(" ", "_")
|
|
||||||
valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false',
|
|
||||||
'paragraph_match', 'short_answer', 'matching', 'essay']
|
|
||||||
if q_type not in valid_types:
|
|
||||||
q_type = "short_answer"
|
|
||||||
diff = q.get("difficulty", "medium").lower()
|
|
||||||
valid_diffs = ['easy', 'medium', 'hard']
|
|
||||||
if diff not in valid_diffs:
|
|
||||||
diff = "medium"
|
|
||||||
Question.create({
|
|
||||||
"name": q.get("prompt", q.get("title", f"{module} question")),
|
|
||||||
"question_type": q_type,
|
|
||||||
"difficulty": diff,
|
|
||||||
"skill": module,
|
|
||||||
"ai_generated": True,
|
|
||||||
})
|
|
||||||
saved += 1
|
|
||||||
except KeyError:
|
|
||||||
saved = len(questions)
|
|
||||||
return _json_response({"saved": saved, "module": module})
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("exam save failed")
|
|
||||||
return _json_response({"saved": 0, "error": str(e)}, 500)
|
|
||||||
|
|
||||||
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
|
|
||||||
@http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def workbench_suggest_materials(self, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": (
|
|
||||||
"You are an educational materials expert. Suggest learning materials "
|
|
||||||
"for the given topic and level. Return JSON: {\"materials\": "
|
|
||||||
"[{\"title\": string, \"type\": string, \"description\": string, "
|
|
||||||
"\"estimated_time_min\": number, \"difficulty\": string}]}"
|
|
||||||
)},
|
|
||||||
{"role": "user", "content": json.dumps(body)},
|
|
||||||
]
|
|
||||||
return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials"))
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"materials": [], "error": str(e)})
|
|
||||||
|
|
||||||
# ── Topic content generation — adaptive ──
|
|
||||||
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def topic_generate_content(self, topic_id, **kw):
|
|
||||||
body = _get_json()
|
|
||||||
try:
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
result = ai.generate_content(
|
|
||||||
body.get("content_type", "explanation"),
|
|
||||||
{"topic_id": topic_id, **body},
|
|
||||||
cefr_level=body.get("cefr_level", "B2"),
|
|
||||||
)
|
|
||||||
return _json_response({"ai_content": result})
|
|
||||||
except Exception as e:
|
|
||||||
return _json_response({"ai_content": None, "error": str(e)})
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
from . import ai_settings
|
|
||||||
from . import ai_log
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
|
@@ -1,7 +0,0 @@
|
|||||||
from .openai_service import OpenAIService
|
|
||||||
from .whisper_service import WhisperService
|
|
||||||
from .polly_service import PollyService
|
|
||||||
from .elevenlabs_service import ElevenLabsService
|
|
||||||
from .gptzero_service import GPTZeroService
|
|
||||||
from .elai_service import ElaiService
|
|
||||||
from .coach_service import CoachService
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""ELAI avatar video generation service."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import requests as _requests
|
|
||||||
except ImportError:
|
|
||||||
_requests = None
|
|
||||||
|
|
||||||
ELAI_BASE = "https://apis.elai.io/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
class ElaiService:
|
|
||||||
"""Generate avatar videos for listening exercises and instructional content."""
|
|
||||||
|
|
||||||
def __init__(self, env):
|
|
||||||
self.env = env
|
|
||||||
self._get_param = env["ir.config_parameter"].sudo().get_param
|
|
||||||
|
|
||||||
def _get_token(self):
|
|
||||||
token = self._get_param("encoach_ai.elai_token", "")
|
|
||||||
if not token:
|
|
||||||
import os
|
|
||||||
token = os.environ.get("ELAI_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise RuntimeError("ELAI token not configured — set in AI Settings")
|
|
||||||
return token
|
|
||||||
|
|
||||||
def _headers(self):
|
|
||||||
return {
|
|
||||||
"Authorization": f"Bearer {self._get_token()}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _log(self, action, latency, status="success", error=None):
|
|
||||||
try:
|
|
||||||
self.env["encoach.ai.log"].sudo().create({
|
|
||||||
"service": "elai",
|
|
||||||
"action": action,
|
|
||||||
"latency_ms": latency,
|
|
||||||
"status": status,
|
|
||||||
"error_message": error,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def list_avatars(self):
|
|
||||||
"""List available ELAI avatars."""
|
|
||||||
if not _requests:
|
|
||||||
raise RuntimeError("requests package not installed")
|
|
||||||
resp = _requests.get(f"{ELAI_BASE}/avatars", headers=self._headers(), timeout=15)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"):
|
|
||||||
"""Create an avatar video from a script.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with 'video_id', 'status'
|
|
||||||
"""
|
|
||||||
if not _requests:
|
|
||||||
raise RuntimeError("requests package not installed")
|
|
||||||
payload = {
|
|
||||||
"name": title,
|
|
||||||
"slides": [
|
|
||||||
{
|
|
||||||
"speech": script,
|
|
||||||
"avatar": avatar_id or "default",
|
|
||||||
"language": language,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
resp = _requests.post(
|
|
||||||
f"{ELAI_BASE}/videos",
|
|
||||||
json=payload,
|
|
||||||
headers=self._headers(),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
self._log("create_video", int((time.time() - t0) * 1000))
|
|
||||||
return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")}
|
|
||||||
except Exception as exc:
|
|
||||||
self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc))
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_video_status(self, video_id):
|
|
||||||
"""Check video generation status."""
|
|
||||||
if not _requests:
|
|
||||||
raise RuntimeError("requests package not installed")
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{ELAI_BASE}/videos/{video_id}",
|
|
||||||
headers=self._headers(),
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
return {
|
|
||||||
"video_id": video_id,
|
|
||||||
"status": data.get("status", "unknown"),
|
|
||||||
"url": data.get("url", ""),
|
|
||||||
"duration": data.get("duration"),
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
from . import ai_course
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
from . import ai_generation_log
|
|
||||||
from . import ai_ielts_generation_log
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
|
||||||
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
|
|
||||||
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
|
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
from .english_pipeline import EnglishPipeline
|
|
||||||
from .ielts_pipeline import IeltsPipeline
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
from . import base
|
|
||||||
from . import auth
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
import jwt as pyjwt
|
|
||||||
|
|
||||||
from odoo import http, fields
|
|
||||||
from odoo.http import request
|
|
||||||
from odoo.exceptions import AccessDenied
|
|
||||||
|
|
||||||
from .base import (
|
|
||||||
_json_response, _error_response, _get_json_body, _get_jwt_secret, validate_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class EncoachAuthController(http.Controller):
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /api/login
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@http.route('/api/login', type='http', auth='public',
|
|
||||||
methods=['POST'], csrf=False)
|
|
||||||
def login(self, **kw):
|
|
||||||
try:
|
|
||||||
body = _get_json_body()
|
|
||||||
login = (body.get('login') or body.get('email') or '').strip().lower()
|
|
||||||
password = body.get('password', '')
|
|
||||||
|
|
||||||
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,
|
|
||||||
'password': password,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
request.session.authenticate(request.env, credential)
|
|
||||||
uid = request.session.uid
|
|
||||||
if not uid:
|
|
||||||
return _error_response('Invalid email or password', 401)
|
|
||||||
except AccessDenied:
|
|
||||||
return _error_response('Invalid email or password', 401)
|
|
||||||
except Exception as auth_err:
|
|
||||||
_logger.warning('Auth error for %s: %s', login, auth_err)
|
|
||||||
return _error_response('Invalid email or password', 401)
|
|
||||||
|
|
||||||
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',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
'user': self._user_to_dict(user),
|
|
||||||
'permissions': permissions,
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception('login failed')
|
|
||||||
return _error_response(str(e), 500)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/user (returns current authenticated user)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@http.route('/api/user', type='http', auth='public',
|
|
||||||
methods=['GET'], csrf=False)
|
|
||||||
def get_current_user(self, **kw):
|
|
||||||
try:
|
|
||||||
user = validate_token()
|
|
||||||
if not user:
|
|
||||||
return _error_response('Authentication required', 401)
|
|
||||||
|
|
||||||
permissions = []
|
|
||||||
if hasattr(user, 'get_all_permissions'):
|
|
||||||
permissions = user.get_all_permissions().mapped('code')
|
|
||||||
|
|
||||||
return _json_response({
|
|
||||||
'user': self._user_to_dict(user),
|
|
||||||
'permissions': permissions,
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception('get_current_user failed')
|
|
||||||
return _error_response(str(e), 500)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /api/logout
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@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.
|
|
||||||
return _json_response({'ok': True})
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _user_to_dict(self, user):
|
|
||||||
"""Convert res.users to the dict shape the React frontend expects."""
|
|
||||||
entities = []
|
|
||||||
if hasattr(user, 'entity_ids'):
|
|
||||||
entities = [
|
|
||||||
{'id': e.id, 'name': e.name, 'role': ''}
|
|
||||||
for e in user.entity_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
classrooms = []
|
|
||||||
|
|
||||||
return {
|
|
||||||
'id': user.id,
|
|
||||||
'name': user.name or '',
|
|
||||||
'email': user.email or '',
|
|
||||||
'login': user.login or '',
|
|
||||||
'user_type': getattr(user, '_api_user_type', lambda: user.user_type or 'student')()
|
|
||||||
if callable(getattr(user, '_api_user_type', None))
|
|
||||||
else (user.user_type or 'student'),
|
|
||||||
'avatar': bool(getattr(user, 'encoach_avatar', False)),
|
|
||||||
'phone': user.phone or '',
|
|
||||||
'country': '',
|
|
||||||
'timezone': '',
|
|
||||||
'bio': '',
|
|
||||||
'gender': getattr(user, 'gender', '') or '',
|
|
||||||
'student_id': '',
|
|
||||||
'is_verified': getattr(user, 'is_verified', False),
|
|
||||||
'entities': entities,
|
|
||||||
'classrooms': classrooms,
|
|
||||||
'expiry_date': '',
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import json
|
|
||||||
import functools
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
import jwt as pyjwt
|
|
||||||
|
|
||||||
from odoo.http import request
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_jwt_secret_cache = {"secret": None, "ts": 0}
|
|
||||||
_JWT_SECRET_TTL = 300
|
|
||||||
|
|
||||||
_user_exists_cache = {}
|
|
||||||
_USER_CACHE_TTL = 60
|
|
||||||
_USER_CACHE_MAX = 200
|
|
||||||
|
|
||||||
|
|
||||||
def _get_jwt_secret():
|
|
||||||
now = time.time()
|
|
||||||
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
|
|
||||||
return _jwt_secret_cache["secret"]
|
|
||||||
secret = (
|
|
||||||
request.env["ir.config_parameter"]
|
|
||||||
.sudo()
|
|
||||||
.get_param("encoach.jwt_secret")
|
|
||||||
)
|
|
||||||
if secret:
|
|
||||||
_jwt_secret_cache["secret"] = secret
|
|
||||||
_jwt_secret_cache["ts"] = now
|
|
||||||
return secret
|
|
||||||
|
|
||||||
|
|
||||||
def validate_token():
|
|
||||||
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None."""
|
|
||||||
auth_header = request.httprequest.headers.get("Authorization", "")
|
|
||||||
if not auth_header.startswith("Bearer "):
|
|
||||||
return None
|
|
||||||
token = auth_header[7:]
|
|
||||||
secret = _get_jwt_secret()
|
|
||||||
if not secret:
|
|
||||||
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
|
|
||||||
except pyjwt.ExpiredSignatureError:
|
|
||||||
return None
|
|
||||||
except pyjwt.InvalidTokenError:
|
|
||||||
return None
|
|
||||||
user_id = payload.get("user_id")
|
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
cache_key = int(user_id)
|
|
||||||
cached = _user_exists_cache.get(cache_key)
|
|
||||||
if cached and (now - cached["ts"]) < _USER_CACHE_TTL:
|
|
||||||
return request.env["res.users"].sudo().browse(cache_key)
|
|
||||||
|
|
||||||
user = request.env["res.users"].sudo().browse(cache_key)
|
|
||||||
if not user.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
_user_exists_cache[cache_key] = {"ts": now}
|
|
||||||
if len(_user_exists_cache) > _USER_CACHE_MAX:
|
|
||||||
oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"])
|
|
||||||
del _user_exists_cache[oldest_key]
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def jwt_required(func):
|
|
||||||
"""Decorator that validates the JWT token and sets request.env user context."""
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
user = validate_token()
|
|
||||||
if not user:
|
|
||||||
return _error_response("Authentication required", status=401)
|
|
||||||
request.update_env(user=user.id)
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def _json_response(data, status=200):
|
|
||||||
return request.make_json_response(data, status=status)
|
|
||||||
|
|
||||||
|
|
||||||
def _error_response(message, status=400, code=None):
|
|
||||||
body = {"error": message}
|
|
||||||
if code:
|
|
||||||
body["code"] = code
|
|
||||||
return request.make_json_response(body, status=status)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_json_body():
|
|
||||||
try:
|
|
||||||
return json.loads(request.httprequest.get_data(as_text=True))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Two calling conventions:
|
|
||||||
_paginate(Model, domain, page, size, order) → (recordset, total_count)
|
|
||||||
_paginate(kwargs_dict) → (offset, limit, page)
|
|
||||||
"""
|
|
||||||
if isinstance(model_or_kwargs, dict):
|
|
||||||
kwargs = model_or_kwargs
|
|
||||||
limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200)
|
|
||||||
page_raw = int(kwargs.get("page", 0))
|
|
||||||
pg = max(page_raw, 0)
|
|
||||||
offset = pg * limit
|
|
||||||
return offset, limit, pg
|
|
||||||
|
|
||||||
Model = model_or_kwargs
|
|
||||||
limit = min(max(int(size), 1), 200)
|
|
||||||
pg = max(int(page), 0)
|
|
||||||
offset = pg * limit
|
|
||||||
total = Model.search_count(domain or [])
|
|
||||||
records = Model.search(domain or [], offset=offset, limit=limit, order=order)
|
|
||||||
return records, total
|
|
||||||
|
|
||||||
|
|
||||||
class EncoachMixin:
|
|
||||||
"""Shared authentication and response helpers for all EnCoach API controllers."""
|
|
||||||
|
|
||||||
def _get_jwt_secret(self):
|
|
||||||
return _get_jwt_secret()
|
|
||||||
|
|
||||||
def _authenticate(self):
|
|
||||||
return validate_token()
|
|
||||||
|
|
||||||
def _json_response(self, data, status=200):
|
|
||||||
return _json_response(data, status=status)
|
|
||||||
|
|
||||||
def _error_response(self, message, status=400, code=None):
|
|
||||||
return _error_response(message, status=status, code=code)
|
|
||||||
|
|
||||||
def _get_json_body(self):
|
|
||||||
return _get_json_body()
|
|
||||||
|
|
||||||
def _paginate_params(self, kwargs):
|
|
||||||
return _paginate(kwargs)
|
|
||||||
|
|
||||||
def _serialize(self, record):
|
|
||||||
if hasattr(record, "to_encoach_dict"):
|
|
||||||
return record.to_encoach_dict()
|
|
||||||
return {"id": record.id}
|
|
||||||
|
|
||||||
def _serialize_list(self, records):
|
|
||||||
return [self._serialize(r) for r in records]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
from . import templates
|
|
||||||
from . import ielts_exam
|
|
||||||
from . import custom_exam
|
|
||||||
from . import exam_structures
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from odoo import http
|
|
||||||
from odoo.http import request
|
|
||||||
|
|
||||||
_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)
|
|
||||||
def list_structures(self, **kw):
|
|
||||||
domain = [('active', '=', True)]
|
|
||||||
entity_id = kw.get('entity_id')
|
|
||||||
if entity_id:
|
|
||||||
domain.append(('entity_id', '=', int(entity_id)))
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for r in records:
|
|
||||||
modules = []
|
|
||||||
if r.modules:
|
|
||||||
try:
|
|
||||||
modules = json.loads(r.modules)
|
|
||||||
except Exception:
|
|
||||||
modules = []
|
|
||||||
items.append({
|
|
||||||
'id': r.id,
|
|
||||||
'name': r.name,
|
|
||||||
'entity_id': r.entity_id.id if r.entity_id else None,
|
|
||||||
'entity_name': r.entity_id.name if r.entity_id else None,
|
|
||||||
'industry': r.industry or '',
|
|
||||||
'modules': modules,
|
|
||||||
'config': json.loads(r.config) if r.config else {},
|
|
||||||
})
|
|
||||||
|
|
||||||
return _json_response({'items': items, 'total': total})
|
|
||||||
|
|
||||||
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
|
|
||||||
def create_structure(self, **kw):
|
|
||||||
body = _json_body()
|
|
||||||
name = body.get('name')
|
|
||||||
if not name:
|
|
||||||
return _json_response({'error': 'name is required'}, status=400)
|
|
||||||
|
|
||||||
vals = {
|
|
||||||
'name': name,
|
|
||||||
'industry': body.get('industry', ''),
|
|
||||||
'modules': json.dumps(body.get('modules', [])),
|
|
||||||
'config': json.dumps(body.get('config', {})),
|
|
||||||
}
|
|
||||||
entity_id = body.get('entity_id')
|
|
||||||
if entity_id:
|
|
||||||
vals['entity_id'] = int(entity_id)
|
|
||||||
|
|
||||||
record = request.env['encoach.exam.structure'].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 [],
|
|
||||||
})
|
|
||||||
|
|
||||||
@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)
|
|
||||||
if not record.exists():
|
|
||||||
return _json_response({'error': 'Structure not found'}, status=404)
|
|
||||||
record.unlink()
|
|
||||||
return _json_response({'success': True})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from odoo import models, fields
|
|
||||||
|
|
||||||
|
|
||||||
class EncoachExamCustom(models.Model):
|
|
||||||
_name = 'encoach.exam.custom'
|
|
||||||
_description = 'Custom Exam'
|
|
||||||
|
|
||||||
title = fields.Char(size=200, required=True)
|
|
||||||
template_id = fields.Many2one('encoach.exam.template', 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')
|
|
||||||
description = fields.Text()
|
|
||||||
total_time_min = fields.Integer()
|
|
||||||
pass_threshold = fields.Float()
|
|
||||||
results_release_mode = fields.Selection([
|
|
||||||
('auto', 'Auto'),
|
|
||||||
('manual_approval', 'Manual Approval'),
|
|
||||||
], default='auto')
|
|
||||||
randomize_questions = fields.Boolean(default=False)
|
|
||||||
status = fields.Selection([
|
|
||||||
('draft', 'Draft'),
|
|
||||||
('published', 'Published'),
|
|
||||||
('archived', 'Archived'),
|
|
||||||
], default='draft', required=True)
|
|
||||||
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
class CefrMapper:
|
|
||||||
"""Maps IRT theta values to CEFR levels and IELTS band scores."""
|
|
||||||
|
|
||||||
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_TO_BAND = {
|
|
||||||
'pre_a1': 2.0,
|
|
||||||
'a1': 3.0,
|
|
||||||
'a2': 4.0,
|
|
||||||
'b1': 5.0,
|
|
||||||
'b2': 6.5,
|
|
||||||
'c1': 7.5,
|
|
||||||
'c2': 9.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
CEFR_LABELS = {
|
|
||||||
'pre_a1': 'Pre-A1 (Beginner)',
|
|
||||||
'a1': 'A1 (Elementary)',
|
|
||||||
'a2': 'A2 (Pre-Intermediate)',
|
|
||||||
'b1': 'B1 (Intermediate)',
|
|
||||||
'b2': 'B2 (Upper-Intermediate)',
|
|
||||||
'c1': 'C1 (Advanced)',
|
|
||||||
'c2': 'C2 (Proficient)',
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def theta_to_cefr(theta):
|
|
||||||
for low, high, level in CefrMapper.THETA_TO_CEFR:
|
|
||||||
if low <= theta < high:
|
|
||||||
return level
|
|
||||||
return 'c2' if theta >= 2.5 else 'pre_a1'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def theta_to_band(theta):
|
|
||||||
cefr = CefrMapper.theta_to_cefr(theta)
|
|
||||||
base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0)
|
|
||||||
|
|
||||||
for low, high, level in CefrMapper.THETA_TO_CEFR:
|
|
||||||
if level == cefr:
|
|
||||||
range_width = high - low
|
|
||||||
if range_width > 0:
|
|
||||||
position = (theta - low) / range_width
|
|
||||||
else:
|
|
||||||
position = 0.5
|
|
||||||
|
|
||||||
cefr_list = list(CefrMapper.CEFR_TO_BAND.keys())
|
|
||||||
idx = cefr_list.index(cefr)
|
|
||||||
next_band = CefrMapper.CEFR_TO_BAND.get(
|
|
||||||
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
band = base_band + position * (next_band - base_band)
|
|
||||||
return round(band * 2) / 2
|
|
||||||
|
|
||||||
return base_band
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def band_to_cefr(band):
|
|
||||||
if band < 2.5:
|
|
||||||
return 'pre_a1'
|
|
||||||
if band < 3.5:
|
|
||||||
return 'a1'
|
|
||||||
if band < 4.5:
|
|
||||||
return 'a2'
|
|
||||||
if band < 5.5:
|
|
||||||
return 'b1'
|
|
||||||
if band < 7.0:
|
|
||||||
return 'b2'
|
|
||||||
if band < 8.0:
|
|
||||||
return 'c1'
|
|
||||||
return 'c2'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_cefr_label(cefr_code):
|
|
||||||
return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
from . import resource
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
from odoo import models, fields
|
|
||||||
|
|
||||||
|
|
||||||
class EncoachResource(models.Model):
|
|
||||||
_name = 'encoach.resource'
|
|
||||||
_description = 'Learning Resource'
|
|
||||||
|
|
||||||
name = fields.Char(required=True)
|
|
||||||
type = fields.Selection([
|
|
||||||
('video', 'Video'),
|
|
||||||
('pdf', 'PDF'),
|
|
||||||
('document', 'Document'),
|
|
||||||
('link', 'Link'),
|
|
||||||
('interactive', 'Interactive'),
|
|
||||||
])
|
|
||||||
review_status = fields.Selection([
|
|
||||||
('pending', 'Pending'),
|
|
||||||
('approved', 'Approved'),
|
|
||||||
('rejected', 'Rejected'),
|
|
||||||
], default='approved')
|
|
||||||
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
|
||||||
topic_ids = fields.Many2many('encoach.topic')
|
|
||||||
file = fields.Binary(attachment=True)
|
|
||||||
url = fields.Char()
|
|
||||||
difficulty = fields.Selection([
|
|
||||||
('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'),
|
|
||||||
])
|
|
||||||
duration_minutes = fields.Integer()
|
|
||||||
active = fields.Boolean(default=True)
|
|
||||||
creator_id = fields.Many2one('res.users', default=lambda self: self.env.user)
|
|
||||||
|
|
||||||
cefr_level = fields.Selection([
|
|
||||||
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
|
|
||||||
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
|
|
||||||
])
|
|
||||||
grammar_topic = fields.Char(size=200)
|
|
||||||
vocab_band = fields.Char(size=50)
|
|
||||||
ai_generated = fields.Boolean(default=False)
|
|
||||||
approved = fields.Boolean(default=False)
|
|
||||||
ielts_certified = fields.Boolean(default=False)
|
|
||||||
|
|
||||||
def to_api_dict(self):
|
|
||||||
self.ensure_one()
|
|
||||||
creator = self.creator_id
|
|
||||||
return {
|
|
||||||
'id': self.id,
|
|
||||||
'name': self.name,
|
|
||||||
'type': self.type or '',
|
|
||||||
'resource_type': self.type or 'document',
|
|
||||||
'subject_id': self.subject_id.id if self.subject_id else None,
|
|
||||||
'topic_ids': self.topic_ids.ids,
|
|
||||||
'topic_names': self.topic_ids.mapped('name'),
|
|
||||||
'url': self.url or '',
|
|
||||||
'has_file': bool(self.file),
|
|
||||||
'difficulty': self.difficulty or '',
|
|
||||||
'duration_minutes': self.duration_minutes,
|
|
||||||
'author_id': creator.id if creator else None,
|
|
||||||
'author_name': creator.name if creator else '',
|
|
||||||
'is_published': bool(self.active),
|
|
||||||
'review_status': self.review_status or 'approved',
|
|
||||||
'cefr_level': self.cefr_level or '',
|
|
||||||
'grammar_topic': self.grammar_topic or '',
|
|
||||||
'vocab_band': self.vocab_band or '',
|
|
||||||
'ai_generated': self.ai_generated,
|
|
||||||
'approved': self.approved,
|
|
||||||
'created_at': self.create_date.isoformat() if self.create_date else '',
|
|
||||||
'file_name': self.name,
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from odoo import http
|
|
||||||
from odoo.http import request
|
|
||||||
from odoo.addons.encoach_api.controllers.base import (
|
|
||||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
|
||||||
)
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
TAXONOMY_MODELS = {
|
|
||||||
'subject': 'encoach.subject',
|
|
||||||
'domain': 'encoach.domain',
|
|
||||||
'topic': 'encoach.topic',
|
|
||||||
'learning_objective': 'encoach.learning.objective',
|
|
||||||
}
|
|
||||||
|
|
||||||
PARENT_FIELD_MAP = {
|
|
||||||
'domain': 'subject_id',
|
|
||||||
'topic': 'domain_id',
|
|
||||||
'learning_objective': 'topic_id',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class EncoachTaxonomyController(http.Controller):
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/taxonomy/subjects
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@http.route('/api/taxonomy/subjects', type='http', auth='none',
|
|
||||||
methods=['GET'], csrf=False)
|
|
||||||
@jwt_required
|
|
||||||
def get_subjects(self, **kw):
|
|
||||||
try:
|
|
||||||
Subject = request.env['encoach.subject'].sudo()
|
|
||||||
subjects = Subject.search([('active', '=', True)])
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for s in subjects:
|
|
||||||
items.append({
|
|
||||||
'id': s.id,
|
|
||||||
'name': s.name,
|
|
||||||
'code': s.code or '',
|
|
||||||
'description': s.description or '',
|
|
||||||
'domain_count': len(s.domain_ids),
|
|
||||||
})
|
|
||||||
|
|
||||||
return _json_response({'subjects': items})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception('get_subjects failed')
|
|
||||||
return _error_response(str(e), 500)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/taxonomy/tree
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@http.route('/api/taxonomy/tree', type='http', auth='none',
|
|
||||||
methods=['GET'], csrf=False)
|
|
||||||
@jwt_required
|
|
||||||
def get_tree(self, **kw):
|
|
||||||
try:
|
|
||||||
Subject = request.env['encoach.subject'].sudo()
|
|
||||||
subjects = Subject.search([('active', '=', True)])
|
|
||||||
|
|
||||||
tree = []
|
|
||||||
for s in subjects:
|
|
||||||
domains = []
|
|
||||||
for d in s.domain_ids:
|
|
||||||
topics = []
|
|
||||||
for t in d.topic_ids:
|
|
||||||
objectives = []
|
|
||||||
for o in t.learning_objective_ids:
|
|
||||||
objectives.append({
|
|
||||||
'id': o.id,
|
|
||||||
'name': o.name,
|
|
||||||
'bloom_level': o.bloom_level or '',
|
|
||||||
'description': o.description or '',
|
|
||||||
})
|
|
||||||
topics.append({
|
|
||||||
'id': t.id,
|
|
||||||
'name': t.name,
|
|
||||||
'description': t.description or '',
|
|
||||||
'learning_objectives': objectives,
|
|
||||||
})
|
|
||||||
domains.append({
|
|
||||||
'id': d.id,
|
|
||||||
'name': d.name,
|
|
||||||
'description': d.description or '',
|
|
||||||
'topics': topics,
|
|
||||||
})
|
|
||||||
tree.append({
|
|
||||||
'id': s.id,
|
|
||||||
'name': s.name,
|
|
||||||
'code': s.code or '',
|
|
||||||
'description': s.description or '',
|
|
||||||
'domains': domains,
|
|
||||||
})
|
|
||||||
|
|
||||||
return _json_response({'tree': tree})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception('get_tree failed')
|
|
||||||
return _error_response(str(e), 500)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /api/taxonomy/node
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@http.route('/api/taxonomy/node', type='http', auth='none',
|
|
||||||
methods=['POST'], csrf=False)
|
|
||||||
@jwt_required
|
|
||||||
def create_node(self, **kw):
|
|
||||||
try:
|
|
||||||
body = _get_json_body()
|
|
||||||
node_type = body.get('type')
|
|
||||||
if not node_type or node_type not in TAXONOMY_MODELS:
|
|
||||||
return _error_response(
|
|
||||||
f'type must be one of: {", ".join(TAXONOMY_MODELS.keys())}', 400,
|
|
||||||
)
|
|
||||||
|
|
||||||
name = body.get('name')
|
|
||||||
if not name:
|
|
||||||
return _error_response('name is required', 400)
|
|
||||||
|
|
||||||
model_name = TAXONOMY_MODELS[node_type]
|
|
||||||
Model = request.env[model_name].sudo()
|
|
||||||
|
|
||||||
vals = {'name': name}
|
|
||||||
if body.get('description'):
|
|
||||||
vals['description'] = body['description']
|
|
||||||
|
|
||||||
if node_type == 'subject':
|
|
||||||
code = body.get('code')
|
|
||||||
if not code:
|
|
||||||
return _error_response('code is required for subjects', 400)
|
|
||||||
vals['code'] = code
|
|
||||||
else:
|
|
||||||
parent_id = body.get('parent_id')
|
|
||||||
if not parent_id:
|
|
||||||
parent_field = PARENT_FIELD_MAP[node_type]
|
|
||||||
return _error_response(
|
|
||||||
f'parent_id ({parent_field}) is required for {node_type}', 400,
|
|
||||||
)
|
|
||||||
vals[PARENT_FIELD_MAP[node_type]] = int(parent_id)
|
|
||||||
|
|
||||||
if node_type == 'learning_objective' and body.get('bloom_level'):
|
|
||||||
vals['bloom_level'] = body['bloom_level']
|
|
||||||
|
|
||||||
record = Model.create(vals)
|
|
||||||
|
|
||||||
return _json_response({
|
|
||||||
'id': record.id,
|
|
||||||
'type': node_type,
|
|
||||||
'name': record.name,
|
|
||||||
}, 201)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception('create_node failed')
|
|
||||||
return _error_response(str(e), 500)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# PUT /api/taxonomy/node/<int:node_id>
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@http.route('/api/taxonomy/node/<int:node_id>', type='http', auth='none',
|
|
||||||
methods=['PUT'], csrf=False)
|
|
||||||
@jwt_required
|
|
||||||
def update_node(self, node_id, **kw):
|
|
||||||
try:
|
|
||||||
body = _get_json_body()
|
|
||||||
node_type = body.get('type')
|
|
||||||
if not node_type or node_type not in TAXONOMY_MODELS:
|
|
||||||
return _error_response(
|
|
||||||
f'type must be one of: {", ".join(TAXONOMY_MODELS.keys())}', 400,
|
|
||||||
)
|
|
||||||
|
|
||||||
model_name = TAXONOMY_MODELS[node_type]
|
|
||||||
Model = request.env[model_name].sudo()
|
|
||||||
record = Model.browse(node_id)
|
|
||||||
if not record.exists():
|
|
||||||
return _error_response(f'{node_type} not found', 404)
|
|
||||||
|
|
||||||
vals = {}
|
|
||||||
if body.get('name'):
|
|
||||||
vals['name'] = body['name']
|
|
||||||
if 'description' in body:
|
|
||||||
vals['description'] = body.get('description') or ''
|
|
||||||
|
|
||||||
if node_type == 'subject':
|
|
||||||
if body.get('code'):
|
|
||||||
vals['code'] = body['code']
|
|
||||||
if 'active' in body:
|
|
||||||
vals['active'] = bool(body['active'])
|
|
||||||
elif node_type == 'learning_objective' and body.get('bloom_level'):
|
|
||||||
vals['bloom_level'] = body['bloom_level']
|
|
||||||
|
|
||||||
if vals:
|
|
||||||
record.write(vals)
|
|
||||||
|
|
||||||
return _json_response(record.to_api_dict())
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception('update_node failed')
|
|
||||||
return _error_response(str(e), 500)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DELETE /api/taxonomy/node/<int:node_id>
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@http.route('/api/taxonomy/node/<int:node_id>', type='http', auth='none',
|
|
||||||
methods=['DELETE'], csrf=False)
|
|
||||||
@jwt_required
|
|
||||||
def delete_node(self, node_id, **kw):
|
|
||||||
try:
|
|
||||||
body = _get_json_body()
|
|
||||||
node_type = body.get('type') or kw.get('type')
|
|
||||||
if not node_type or node_type not in TAXONOMY_MODELS:
|
|
||||||
return _error_response(
|
|
||||||
f'type must be one of: {", ".join(TAXONOMY_MODELS.keys())}', 400,
|
|
||||||
)
|
|
||||||
|
|
||||||
model_name = TAXONOMY_MODELS[node_type]
|
|
||||||
Model = request.env[model_name].sudo()
|
|
||||||
record = Model.browse(node_id)
|
|
||||||
if not record.exists():
|
|
||||||
return _error_response(f'{node_type} not found', 404)
|
|
||||||
|
|
||||||
record.unlink()
|
|
||||||
return _json_response({}, 204)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception('delete_node failed')
|
|
||||||
return _error_response(str(e), 500)
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
"""Embedding service — encode text and manage vector storage."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_model_instance = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_model():
|
|
||||||
"""Lazy-load the sentence-transformers model (cached across calls)."""
|
|
||||||
global _model_instance
|
|
||||||
if _model_instance is None:
|
|
||||||
try:
|
|
||||||
from sentence_transformers import SentenceTransformer
|
|
||||||
_model_instance = SentenceTransformer('all-MiniLM-L6-v2')
|
|
||||||
_logger.info("Loaded sentence-transformers model: all-MiniLM-L6-v2")
|
|
||||||
except ImportError:
|
|
||||||
_logger.error(
|
|
||||||
"sentence-transformers not installed. "
|
|
||||||
"Run: pip install sentence-transformers"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
return _model_instance
|
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingService:
|
|
||||||
"""Encode texts, upsert embeddings, and perform semantic search."""
|
|
||||||
|
|
||||||
def __init__(self, env):
|
|
||||||
self.env = env
|
|
||||||
self.Embedding = env['encoach.embedding'].sudo()
|
|
||||||
|
|
||||||
def encode(self, texts):
|
|
||||||
"""Batch-encode texts to vectors.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
texts: list of strings
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list of float lists (each 384-dim)
|
|
||||||
"""
|
|
||||||
model = _get_model()
|
|
||||||
embeddings = model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
|
|
||||||
return [e.tolist() for e in embeddings]
|
|
||||||
|
|
||||||
def upsert(self, content_type, content_id, text, metadata=None):
|
|
||||||
"""Encode and store (or update) a single embedding.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
encoach.embedding record
|
|
||||||
"""
|
|
||||||
if not text or not text.strip():
|
|
||||||
return None
|
|
||||||
|
|
||||||
existing = self.Embedding.search([
|
|
||||||
('content_type', '=', content_type),
|
|
||||||
('content_id', '=', content_id),
|
|
||||||
], limit=1)
|
|
||||||
|
|
||||||
vectors = self.encode([text])
|
|
||||||
meta_str = json.dumps(metadata or {})
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.write({
|
|
||||||
'content_text': text[:10000],
|
|
||||||
'metadata_json': meta_str,
|
|
||||||
})
|
|
||||||
existing.set_embedding(vectors[0])
|
|
||||||
return existing
|
|
||||||
|
|
||||||
record = self.Embedding.create({
|
|
||||||
'content_type': content_type,
|
|
||||||
'content_id': content_id,
|
|
||||||
'content_text': text[:10000],
|
|
||||||
'metadata_json': meta_str,
|
|
||||||
})
|
|
||||||
record.set_embedding(vectors[0])
|
|
||||||
return record
|
|
||||||
|
|
||||||
def search(self, query, *, content_type=None, limit=10):
|
|
||||||
"""Semantic search — encode query and find similar content.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list of dicts with text, metadata, similarity score
|
|
||||||
"""
|
|
||||||
if not query or not query.strip():
|
|
||||||
return []
|
|
||||||
|
|
||||||
t0 = time.time()
|
|
||||||
vectors = self.encode([query])
|
|
||||||
results = self.Embedding.similarity_search(
|
|
||||||
vectors[0],
|
|
||||||
content_type=content_type,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
latency = int((time.time() - t0) * 1000)
|
|
||||||
_logger.info("Vector search for '%s' returned %d results in %dms",
|
|
||||||
query[:80], len(results), latency)
|
|
||||||
return results
|
|
||||||
|
|
||||||
def bulk_index(self, content_type, records_data):
|
|
||||||
"""Batch-index multiple records.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_type: embedding content type
|
|
||||||
records_data: list of dicts with keys: id, text, metadata
|
|
||||||
"""
|
|
||||||
if not records_data:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
texts = [r['text'] for r in records_data if r.get('text')]
|
|
||||||
if not texts:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
vectors = self.encode(texts)
|
|
||||||
|
|
||||||
indexed = 0
|
|
||||||
text_idx = 0
|
|
||||||
for r in records_data:
|
|
||||||
if not r.get('text'):
|
|
||||||
continue
|
|
||||||
self.upsert(content_type, r['id'], r['text'], r.get('metadata'))
|
|
||||||
text_idx += 1
|
|
||||||
indexed += 1
|
|
||||||
|
|
||||||
_logger.info("Bulk-indexed %d %s records", indexed, content_type)
|
|
||||||
return indexed
|
|
||||||
|
|
||||||
def delete(self, content_type, content_id):
|
|
||||||
"""Remove an embedding."""
|
|
||||||
existing = self.Embedding.search([
|
|
||||||
('content_type', '=', content_type),
|
|
||||||
('content_id', '=', content_id),
|
|
||||||
])
|
|
||||||
if existing:
|
|
||||||
existing.unlink()
|
|
||||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 903 KiB After Width: | Height: | Size: 903 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 680 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 726 KiB |
|
Before Width: | Height: | Size: 746 KiB After Width: | Height: | Size: 746 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 489 KiB After Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 525 B After Width: | Height: | Size: 525 B |
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 780 KiB After Width: | Height: | Size: 780 KiB |
|
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 860 KiB After Width: | Height: | Size: 860 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 5.3 MiB After Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -27,14 +27,33 @@ class EncoachAdaptiveController(http.Controller):
|
|||||||
from odoo.fields import Datetime as DT
|
from odoo.fields import Datetime as DT
|
||||||
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
total_students = len(Path.search([]).mapped('student_id'))
|
all_paths = Path.search([])
|
||||||
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
|
total_students = len(all_paths.mapped('student_id'))
|
||||||
|
active_courses = len(all_paths.mapped('course_id').filtered(lambda c: c))
|
||||||
|
|
||||||
signals_today = Event.search_count([
|
signals_today = Event.search_count([
|
||||||
('event_type', '=', 'signal'),
|
('event_type', '=', 'signal'),
|
||||||
('created_at', '>=', today_start),
|
('created_at', '>=', today_start),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
avg_progress = 0.0
|
||||||
|
if all_paths:
|
||||||
|
progress_values = []
|
||||||
|
for p in all_paths:
|
||||||
|
try:
|
||||||
|
module_queue = json.loads(p.module_queue or '[]')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
module_queue = []
|
||||||
|
total_modules = len(module_queue) if module_queue else 1
|
||||||
|
completed = sum(
|
||||||
|
1 for m in module_queue
|
||||||
|
if isinstance(m, dict) and m.get('done')
|
||||||
|
)
|
||||||
|
progress_values.append(
|
||||||
|
round(completed / total_modules * 100, 1) if total_modules else 0.0
|
||||||
|
)
|
||||||
|
avg_progress = round(sum(progress_values) / len(progress_values), 1) if progress_values else 0.0
|
||||||
|
|
||||||
recent_decisions = []
|
recent_decisions = []
|
||||||
decisions = Event.search(
|
decisions = Event.search(
|
||||||
[('event_type', '=', 'decision')],
|
[('event_type', '=', 'decision')],
|
||||||
@@ -52,7 +71,7 @@ class EncoachAdaptiveController(http.Controller):
|
|||||||
return _json_response({
|
return _json_response({
|
||||||
'total_students': total_students,
|
'total_students': total_students,
|
||||||
'active_courses': active_courses,
|
'active_courses': active_courses,
|
||||||
'avg_progress': 0.0,
|
'avg_progress': avg_progress,
|
||||||
'signals_today': signals_today,
|
'signals_today': signals_today,
|
||||||
'recent_decisions': recent_decisions,
|
'recent_decisions': recent_decisions,
|
||||||
})
|
})
|
||||||