Compare commits
74 Commits
v3
...
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 | ||
|
|
b02ee8b6b7 | ||
|
|
0c8443256d | ||
|
|
140ca7408d | ||
|
|
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 +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,3 +0,0 @@
|
|||||||
from . import templates
|
|
||||||
from . import ielts_exam
|
|
||||||
from . import custom_exam
|
|
||||||
@@ -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,67 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SpeakingEvaluator:
|
|
||||||
"""AI-powered speaking assessment using Whisper + GPT."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def transcribe_audio(audio_path):
|
|
||||||
"""Transcribe audio using Whisper."""
|
|
||||||
try:
|
|
||||||
import whisper
|
|
||||||
model = whisper.load_model("base")
|
|
||||||
result = model.transcribe(audio_path)
|
|
||||||
return {
|
|
||||||
'text': result['text'],
|
|
||||||
'language': result.get('language', 'en'),
|
|
||||||
'segments': result.get('segments', []),
|
|
||||||
}
|
|
||||||
except ImportError:
|
|
||||||
_logger.warning("whisper not installed")
|
|
||||||
return {'text': '', 'language': 'en', 'segments': [], 'error': 'Whisper not available'}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def evaluate_speaking(transcription, rubric_criteria, target_band=6.0):
|
|
||||||
"""Evaluate speaking using OpenAI GPT."""
|
|
||||||
try:
|
|
||||||
import openai
|
|
||||||
|
|
||||||
prompt = (
|
|
||||||
"You are an IELTS speaking examiner. Evaluate the following speaking response.\n\n"
|
|
||||||
f"Target Band: {target_band}\n\n"
|
|
||||||
f"Rubric Criteria:\n{rubric_criteria}\n\n"
|
|
||||||
f"Transcription:\n{transcription}\n\n"
|
|
||||||
"Provide scores for each criterion (0-9 scale) and detailed feedback.\n"
|
|
||||||
"Return JSON format:\n"
|
|
||||||
"{\n"
|
|
||||||
' "fluency_coherence": {"score": X, "feedback": "..."},\n'
|
|
||||||
' "lexical_resource": {"score": X, "feedback": "..."},\n'
|
|
||||||
' "grammatical_range": {"score": X, "feedback": "..."},\n'
|
|
||||||
' "pronunciation": {"score": X, "feedback": "..."},\n'
|
|
||||||
' "overall_band": X,\n'
|
|
||||||
' "general_feedback": "..."\n'
|
|
||||||
"}"
|
|
||||||
)
|
|
||||||
|
|
||||||
client = openai.OpenAI()
|
|
||||||
response = client.chat.completions.create(
|
|
||||||
model="gpt-4",
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": "You are an expert IELTS speaking examiner."},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
|
||||||
temperature=0.3,
|
|
||||||
)
|
|
||||||
|
|
||||||
import json
|
|
||||||
result = json.loads(response.choices[0].message.content)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
_logger.warning("openai not installed")
|
|
||||||
return {'overall_band': 0, 'general_feedback': 'AI evaluation not available', 'error': 'OpenAI not available'}
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error("Speaking evaluation error: %s", e)
|
|
||||||
return {'overall_band': 0, 'general_feedback': f'Evaluation error: {e}'}
|
|
||||||
@@ -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)
|
|
||||||
|
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 |
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
from odoo.addons.encoach_api.controllers.base import (
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
@@ -26,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')],
|
||||||
@@ -51,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,
|
||||||
})
|
})
|
||||||
@@ -164,6 +184,44 @@ class EncoachAdaptiveController(http.Controller):
|
|||||||
_logger.exception('student signals failed')
|
_logger.exception('student signals failed')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/adaptive/student/<int:student_id>/ability
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/adaptive/student/<int:student_id>/ability', type='http',
|
||||||
|
auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def student_ability(self, student_id, **kw):
|
||||||
|
try:
|
||||||
|
Event = request.env['encoach.adaptive.event'].sudo()
|
||||||
|
signals = Event.search([
|
||||||
|
('student_id', '=', student_id),
|
||||||
|
('event_type', '=', 'signal'),
|
||||||
|
], order='created_at asc')
|
||||||
|
|
||||||
|
trajectory = []
|
||||||
|
for s in signals:
|
||||||
|
trajectory.append({
|
||||||
|
'signal_name': s.signal_name or '',
|
||||||
|
'value': s.signal_value,
|
||||||
|
'timestamp': s.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
values = [s.signal_value for s in signals if s.signal_value]
|
||||||
|
theta = sum(values) / len(values) if values else 0.0
|
||||||
|
sem = math.sqrt(sum((v - theta) ** 2 for v in values) / len(values)) if len(values) > 1 else 1.0
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'student_id': student_id,
|
||||||
|
'theta': round(theta, 3),
|
||||||
|
'sem': round(sem, 3),
|
||||||
|
'trajectory': trajectory,
|
||||||
|
'n_signals': len(trajectory),
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('student ability failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/adaptive/student/<int:student_id>/recommended-resources
|
# GET /api/adaptive/student/<int:student_id>/recommended-resources
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||