Compare commits
6 Commits
feature/te
...
a3e12f62fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e12f62fa | ||
|
|
5ff9f12de7 | ||
|
|
e30e52e21e | ||
|
|
d98cd55b99 | ||
|
|
6c93c5d600 | ||
| a4b9ab62cf |
33
.gitignore
vendored
33
.gitignore
vendored
@@ -26,3 +26,36 @@ Thumbs.db
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
*.tar
|
*.tar
|
||||||
|
|
||||||
|
# Frontend repo (separate repository)
|
||||||
|
new_project/encoach_frontend_new_v1/
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
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
|
||||||
|
|||||||
156
new_project/Developer Workflow Manual.txt
Normal file
156
new_project/Developer Workflow Manual.txt
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Developer Workflow Manual — EnCoach Backend & Frontend
|
||||||
|
|
||||||
|
## One-Time Setup (Do This Once)
|
||||||
|
|
||||||
|
**Step 1 — Clone the repositories**
|
||||||
|
```bash
|
||||||
|
git clone https://git.albousalh.com/devops/encoach_backend_new_v2.git
|
||||||
|
git clone https://git.albousalh.com/devops/encoach_frontend_new_v2.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 — Switch to the working branch**
|
||||||
|
```bash
|
||||||
|
cd encoach_backend_new_v2
|
||||||
|
git checkout full_stack_dev
|
||||||
|
|
||||||
|
cd ../encoach_frontend_new_v2
|
||||||
|
git checkout full_stack_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — Install the `tea` CLI (to open PRs from terminal)**
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install tea
|
||||||
|
|
||||||
|
# Or download directly:
|
||||||
|
# https://gitea.com/gitea/tea/releases
|
||||||
|
|
||||||
|
# Configure it (run once):
|
||||||
|
tea login add --name encoach --url https://git.albousalh.com --token YOUR_TOKEN
|
||||||
|
```
|
||||||
|
> To get your token: log in at `https://git.albousalh.com` → top-right avatar → Settings → Applications → Generate Token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Every Day — Starting Work
|
||||||
|
|
||||||
|
**Step 4 — Always pull the latest before coding**
|
||||||
|
```bash
|
||||||
|
git checkout full_stack_dev
|
||||||
|
git pull origin full_stack_dev
|
||||||
|
```
|
||||||
|
> This makes sure you have the latest code before making changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
**Step 5 — Make your code changes locally**
|
||||||
|
|
||||||
|
Work as normal in your editor. No restrictions on what you change.
|
||||||
|
|
||||||
|
**Step 6 — If you add a new Python package**, add it to `new_project/requirements.txt`:
|
||||||
|
```
|
||||||
|
openai>=1.50
|
||||||
|
your-new-package>=x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 7 — If you add a new Odoo addon**, place it under `new_project/custom_addons/your_module/` and make sure:
|
||||||
|
- The `__manifest__.py` lists **all** modules you reference via `comodel_name=` in its `depends` list.
|
||||||
|
- Notify Talal — new addons need a one-time manual install on the server (see end of this doc).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pushing Your Work
|
||||||
|
|
||||||
|
**Step 8 — Stage and commit your changes**
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Short description of what you changed"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 9 — Push to your working branch**
|
||||||
|
```bash
|
||||||
|
git push origin full_stack_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 10 — Open a Pull Request** (asks Talal for review/approval)
|
||||||
|
```bash
|
||||||
|
tea pr create \
|
||||||
|
--repo devops/encoach_backend_new_v2 \
|
||||||
|
--head full_stack_dev \
|
||||||
|
--base main \
|
||||||
|
--title "Brief title of your changes" \
|
||||||
|
--description "What you changed and why"
|
||||||
|
```
|
||||||
|
> For frontend, replace `encoach_backend_new_v2` with `encoach_frontend_new_v2`.
|
||||||
|
|
||||||
|
**Step 11 — Notify Talal** that a PR is waiting for his review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Talal Approves & Merges
|
||||||
|
|
||||||
|
**Step 12 — Deployment is fully automatic.** You do not need to do anything on the server.
|
||||||
|
|
||||||
|
What happens behind the scenes:
|
||||||
|
1. Talal merges the PR on `https://git.albousalh.com`
|
||||||
|
2. The server detects the merge to `main`
|
||||||
|
3. The deploy script pulls the latest code
|
||||||
|
4. Docker rebuilds the image (if `Dockerfile` or `requirements.txt` changed — takes ~10–15 min; otherwise ~1 min)
|
||||||
|
5. The container restarts with the new code
|
||||||
|
|
||||||
|
**Step 13 — Verify it worked** by checking the live URL:
|
||||||
|
- Frontend: `http://5.189.151.117:3000`
|
||||||
|
- Backend API: `http://5.189.151.117:8069/api/login`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking Logs If Something Goes Wrong
|
||||||
|
|
||||||
|
SSH into the server (ask Talal for credentials) and run:
|
||||||
|
```bash
|
||||||
|
# Backend logs
|
||||||
|
docker logs backend-v2-odoo --tail 50
|
||||||
|
|
||||||
|
# Frontend logs
|
||||||
|
docker logs encoach-frontend --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Case: Adding a New Odoo Addon
|
||||||
|
|
||||||
|
New addons need a **one-time manual install** on the server (the database doesn't know about them yet). Tell Talal and he will run:
|
||||||
|
```bash
|
||||||
|
ssh root@5.189.151.117
|
||||||
|
docker stop backend-v2-odoo
|
||||||
|
docker run --rm \
|
||||||
|
--network backend-v2_default \
|
||||||
|
-v /opt/encoach/backend-v2/odoo-docker.conf:/etc/odoo/odoo.conf:ro \
|
||||||
|
-v /opt/encoach/backend-v2/new_project/custom_addons:/mnt/custom_addons:ro \
|
||||||
|
-v /opt/encoach/backend-v2/new_project/enterprise-19:/mnt/enterprise:ro \
|
||||||
|
-v /opt/encoach/backend-v2/new_project/openeducat_erp-19.0:/mnt/openeducat:ro \
|
||||||
|
-v odoo-web-data:/var/lib/odoo \
|
||||||
|
encoach-backend:latest \
|
||||||
|
odoo -c /etc/odoo/odoo.conf -d encoach_v2 -i your_new_module --stop-after-init
|
||||||
|
docker start backend-v2-odoo
|
||||||
|
```
|
||||||
|
> After the first install, all future deploys update it automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
|---|---|
|
||||||
|
| Pull latest | `git pull origin full_stack_dev` |
|
||||||
|
| Commit | `git add . && git commit -m "message"` |
|
||||||
|
| Push | `git push origin full_stack_dev` |
|
||||||
|
| Open PR (backend) | `tea pr create --repo devops/encoach_backend_new_v2 --head full_stack_dev --base main --title "..."` |
|
||||||
|
| Open PR (frontend) | `tea pr create --repo devops/encoach_frontend_new_v2 --head full_stack_dev --base main --title "..."` |
|
||||||
|
| View logs (backend) | `docker logs backend-v2-odoo --tail 50` |
|
||||||
|
| View logs (frontend) | `docker logs encoach-frontend --tail 50` |
|
||||||
2479
new_project/ENCOACH_WORKFLOWS_BACKEND_SRS.md
Normal file
2479
new_project/ENCOACH_WORKFLOWS_BACKEND_SRS.md
Normal file
File diff suppressed because it is too large
Load Diff
2109
new_project/ENCOACH_WORKFLOWS_FRONTEND_SRS.md
Normal file
2109
new_project/ENCOACH_WORKFLOWS_FRONTEND_SRS.md
Normal file
File diff suppressed because it is too large
Load Diff
3
new_project/custom_addons/encoach_adaptive/__init__.py
Normal file
3
new_project/custom_addons/encoach_adaptive/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import services
|
||||||
26
new_project/custom_addons/encoach_adaptive/__manifest__.py
Normal file
26
new_project/custom_addons/encoach_adaptive/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
'name': 'EnCoach Adaptive Learning',
|
||||||
|
'version': '19.0.1.0',
|
||||||
|
'category': 'Education',
|
||||||
|
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
|
||||||
|
'author': 'EnCoach',
|
||||||
|
'depends': ['encoach_core', 'encoach_taxonomy', 'mail'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/ir_cron.xml',
|
||||||
|
'views/adaptive_event_views.xml',
|
||||||
|
'views/adaptive_path_views.xml',
|
||||||
|
'views/adaptive_settings_views.xml',
|
||||||
|
'views/signal_timeline_action.xml',
|
||||||
|
'views/adaptive_menus.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'encoach_adaptive/static/src/js/signal_timeline.js',
|
||||||
|
'encoach_adaptive/static/src/xml/signal_timeline.xml',
|
||||||
|
'encoach_adaptive/static/src/css/signal_timeline.css',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import adaptive
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
from odoo.addons.encoach_adaptive.services.style_matcher import STYLE_RESOURCE_MAP
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAdaptiveController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/adaptive/dashboard
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/adaptive/dashboard', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def dashboard(self, **kw):
|
||||||
|
try:
|
||||||
|
Event = request.env['encoach.adaptive.event'].sudo()
|
||||||
|
Path = request.env['encoach.adaptive.path'].sudo()
|
||||||
|
|
||||||
|
from odoo.fields import Datetime as DT
|
||||||
|
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
total_students = len(Path.search([]).mapped('student_id'))
|
||||||
|
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
|
||||||
|
|
||||||
|
signals_today = Event.search_count([
|
||||||
|
('event_type', '=', 'signal'),
|
||||||
|
('created_at', '>=', today_start),
|
||||||
|
])
|
||||||
|
|
||||||
|
recent_decisions = []
|
||||||
|
decisions = Event.search(
|
||||||
|
[('event_type', '=', 'decision')],
|
||||||
|
limit=10, order='created_at desc',
|
||||||
|
)
|
||||||
|
for d in decisions:
|
||||||
|
recent_decisions.append({
|
||||||
|
'id': d.id,
|
||||||
|
'student_id': d.student_id.id,
|
||||||
|
'student_name': d.student_id.name or '',
|
||||||
|
'decision': d.decision or '',
|
||||||
|
'created_at': d.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'total_students': total_students,
|
||||||
|
'active_courses': active_courses,
|
||||||
|
'avg_progress': 0.0,
|
||||||
|
'signals_today': signals_today,
|
||||||
|
'recent_decisions': recent_decisions,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('adaptive dashboard failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/adaptive/students
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/adaptive/students', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def students(self, **kw):
|
||||||
|
try:
|
||||||
|
page = int(kw.get('page', 1))
|
||||||
|
size = int(kw.get('size', 20))
|
||||||
|
|
||||||
|
Path = request.env['encoach.adaptive.path'].sudo()
|
||||||
|
Event = request.env['encoach.adaptive.event'].sudo()
|
||||||
|
paths, total = _paginate(Path, [], page, size, order='id desc')
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for p in paths:
|
||||||
|
last_signal = Event.search(
|
||||||
|
[('student_id', '=', p.student_id.id), ('event_type', '=', 'signal')],
|
||||||
|
limit=1, order='created_at desc',
|
||||||
|
)
|
||||||
|
|
||||||
|
module_queue = []
|
||||||
|
try:
|
||||||
|
module_queue = json.loads(p.module_queue or '[]')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
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_pct = round(completed / total_modules * 100, 1) if total_modules else 0.0
|
||||||
|
|
||||||
|
current_module = ''
|
||||||
|
if module_queue and completed < len(module_queue):
|
||||||
|
entry = module_queue[completed]
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
current_module = entry.get('name', '')
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
current_module = entry
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'student_id': p.student_id.id,
|
||||||
|
'name': p.student_id.name or '',
|
||||||
|
'course': p.course_id.name if p.course_id else '',
|
||||||
|
'current_module': current_module,
|
||||||
|
'progress_pct': progress_pct,
|
||||||
|
'last_signal': {
|
||||||
|
'signal_name': last_signal.signal_name or '',
|
||||||
|
'signal_value': last_signal.signal_value,
|
||||||
|
'created_at': last_signal.created_at,
|
||||||
|
} if last_signal else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'items': items,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('adaptive students list failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/adaptive/student/<int:student_id>/signals
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/adaptive/student/<int:student_id>/signals', type='http',
|
||||||
|
auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def student_signals(self, student_id, **kw):
|
||||||
|
try:
|
||||||
|
page = int(kw.get('page', 1))
|
||||||
|
size = int(kw.get('size', 20))
|
||||||
|
|
||||||
|
Event = request.env['encoach.adaptive.event'].sudo()
|
||||||
|
domain = [('student_id', '=', student_id)]
|
||||||
|
events, total = _paginate(Event, domain, page, size, order='created_at desc')
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for ev in events:
|
||||||
|
items.append({
|
||||||
|
'id': ev.id,
|
||||||
|
'event_type': ev.event_type,
|
||||||
|
'signal_name': ev.signal_name or '',
|
||||||
|
'signal_value': ev.signal_value,
|
||||||
|
'decision': ev.decision or '',
|
||||||
|
'created_at': ev.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'items': items,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('student signals failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/adaptive/student/<int:student_id>/recommended-resources
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/adaptive/student/<int:student_id>/recommended-resources',
|
||||||
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def recommended_resources(self, student_id, **kw):
|
||||||
|
try:
|
||||||
|
profile = request.env['encoach.student.profile'].sudo().search(
|
||||||
|
[('user_id', '=', student_id)], limit=1)
|
||||||
|
learning_style = profile.learning_style if profile else ''
|
||||||
|
|
||||||
|
# Get resources for student's current course/module
|
||||||
|
path = request.env['encoach.adaptive.path'].sudo().search(
|
||||||
|
[('student_id', '=', student_id)], limit=1, order='id desc')
|
||||||
|
|
||||||
|
resources = request.env['encoach.resource'].sudo().search(
|
||||||
|
[('active', '=', True), ('review_status', '=', 'approved')], limit=50)
|
||||||
|
|
||||||
|
if learning_style:
|
||||||
|
from odoo.addons.encoach_adaptive.services.style_matcher import StyleMatcher
|
||||||
|
ranked = StyleMatcher.rank_resources(learning_style, resources)
|
||||||
|
else:
|
||||||
|
ranked = list(resources)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in ranked[:20]:
|
||||||
|
items.append({
|
||||||
|
'id': r.id,
|
||||||
|
'name': r.name,
|
||||||
|
'type': r.type or '',
|
||||||
|
'cefr_level': r.cefr_level or '',
|
||||||
|
'difficulty': r.difficulty or '',
|
||||||
|
'duration_minutes': r.duration_minutes,
|
||||||
|
'style_match': learning_style if r.type in (
|
||||||
|
STYLE_RESOURCE_MAP.get(learning_style, []) if learning_style else []
|
||||||
|
) else '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'student_id': student_id,
|
||||||
|
'learning_style': learning_style or 'none',
|
||||||
|
'items': items,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('recommended_resources failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/adaptive/settings
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/adaptive/settings', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_settings(self, **kw):
|
||||||
|
try:
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
Settings = request.env['encoach.adaptive.settings'].sudo()
|
||||||
|
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
|
||||||
|
|
||||||
|
if not settings:
|
||||||
|
return _json_response({
|
||||||
|
'step_up_threshold': 0.85,
|
||||||
|
'step_down_threshold': 0.50,
|
||||||
|
'micro_lesson_trigger': 2,
|
||||||
|
'module_skip_threshold': 0.95,
|
||||||
|
'no_progress_alert_days': 3,
|
||||||
|
'max_retries': 3,
|
||||||
|
'is_default': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'id': settings.id,
|
||||||
|
'step_up_threshold': settings.step_up_threshold,
|
||||||
|
'step_down_threshold': settings.step_down_threshold,
|
||||||
|
'micro_lesson_trigger': settings.micro_lesson_trigger,
|
||||||
|
'module_skip_threshold': settings.module_skip_threshold,
|
||||||
|
'no_progress_alert_days': settings.no_progress_alert_days,
|
||||||
|
'max_retries': settings.max_retries,
|
||||||
|
'is_default': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get adaptive settings failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PUT /api/adaptive/settings
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/adaptive/settings', type='http', auth='none',
|
||||||
|
methods=['PUT'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def update_settings(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
Settings = request.env['encoach.adaptive.settings'].sudo()
|
||||||
|
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
|
||||||
|
|
||||||
|
allowed_fields = [
|
||||||
|
'step_up_threshold', 'step_down_threshold', 'micro_lesson_trigger',
|
||||||
|
'module_skip_threshold', 'no_progress_alert_days', 'max_retries',
|
||||||
|
]
|
||||||
|
vals = {k: body[k] for k in allowed_fields if k in body}
|
||||||
|
|
||||||
|
if not settings:
|
||||||
|
vals['teacher_id'] = user.id
|
||||||
|
entity = user.entity_ids[:1]
|
||||||
|
if entity:
|
||||||
|
vals['entity_id'] = entity.id
|
||||||
|
settings = Settings.create(vals)
|
||||||
|
else:
|
||||||
|
settings.write(vals)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'id': settings.id,
|
||||||
|
'step_up_threshold': settings.step_up_threshold,
|
||||||
|
'step_down_threshold': settings.step_down_threshold,
|
||||||
|
'micro_lesson_trigger': settings.micro_lesson_trigger,
|
||||||
|
'module_skip_threshold': settings.module_skip_threshold,
|
||||||
|
'no_progress_alert_days': settings.no_progress_alert_days,
|
||||||
|
'max_retries': settings.max_retries,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('update adaptive settings failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
14
new_project/custom_addons/encoach_adaptive/data/ir_cron.xml
Normal file
14
new_project/custom_addons/encoach_adaptive/data/ir_cron.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="ir_cron_adaptive_no_progress_alert" model="ir.cron">
|
||||||
|
<field name="name">EnCoach: Check Student Progress Alerts</field>
|
||||||
|
<field name="model_id" search="[('model', '=', 'encoach.adaptive.settings')]"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_check_no_progress()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from . import adaptive_event
|
||||||
|
from . import adaptive_path
|
||||||
|
from . import adaptive_settings
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAdaptiveEvent(models.Model):
|
||||||
|
_name = 'encoach.adaptive.event'
|
||||||
|
_description = 'Adaptive Learning Event'
|
||||||
|
_order = 'created_at desc'
|
||||||
|
|
||||||
|
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||||
|
course_id = fields.Many2one('op.course', ondelete='set null')
|
||||||
|
event_type = fields.Selection([
|
||||||
|
('signal', 'Signal'),
|
||||||
|
('decision', 'Decision'),
|
||||||
|
], required=True)
|
||||||
|
signal_name = fields.Char(size=100)
|
||||||
|
signal_value = fields.Float()
|
||||||
|
decision = fields.Char(size=200)
|
||||||
|
context = fields.Text()
|
||||||
|
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAdaptivePath(models.Model):
|
||||||
|
_name = 'encoach.adaptive.path'
|
||||||
|
_description = 'Adaptive Learning Path'
|
||||||
|
|
||||||
|
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||||
|
course_id = fields.Many2one('op.course', ondelete='set null')
|
||||||
|
module_queue = fields.Text()
|
||||||
|
source = fields.Selection([
|
||||||
|
('placement', 'Placement'),
|
||||||
|
('exam', 'Exam'),
|
||||||
|
('ai_generated', 'AI Generated'),
|
||||||
|
])
|
||||||
|
next_generation_brief = fields.Text()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from odoo import api, models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAdaptiveSettings(models.Model):
|
||||||
|
_name = 'encoach.adaptive.settings'
|
||||||
|
_description = 'Adaptive Engine Settings'
|
||||||
|
|
||||||
|
teacher_id = fields.Many2one('res.users', ondelete='cascade')
|
||||||
|
entity_id = fields.Many2one('encoach.entity', ondelete='cascade')
|
||||||
|
step_up_threshold = fields.Float(default=0.85)
|
||||||
|
step_down_threshold = fields.Float(default=0.50)
|
||||||
|
micro_lesson_trigger = fields.Integer(default=2)
|
||||||
|
module_skip_threshold = fields.Float(default=0.95)
|
||||||
|
no_progress_alert_days = fields.Integer(default=3)
|
||||||
|
max_retries = fields.Integer(default=3)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_check_no_progress(self):
|
||||||
|
from odoo.addons.encoach_adaptive.services.alert_service import AdaptiveAlertService
|
||||||
|
AdaptiveAlertService.check_no_progress(self.env)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_adaptive_event_user,encoach.adaptive.event.user,model_encoach_adaptive_event,base.group_user,1,1,1,1
|
||||||
|
access_adaptive_path_user,encoach.adaptive.path.user,model_encoach_adaptive_path,base.group_user,1,1,1,1
|
||||||
|
access_adaptive_settings_user,encoach.adaptive.settings.user,model_encoach_adaptive_settings,base.group_user,1,1,1,1
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
from .adaptive_engine import AdaptiveEngine
|
||||||
|
from . import style_matcher
|
||||||
|
from . import alert_service
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveEngine:
|
||||||
|
"""4-phase adaptive learning engine.
|
||||||
|
|
||||||
|
Phase 1: Module-level up/down stepping
|
||||||
|
Phase 2: Micro-lesson injection
|
||||||
|
Phase 3: Module skipping
|
||||||
|
Phase 4: No-progress alerts
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
'step_up_threshold': 0.85,
|
||||||
|
'step_down_threshold': 0.50,
|
||||||
|
'micro_lesson_trigger': 2,
|
||||||
|
'module_skip_threshold': 0.95,
|
||||||
|
'no_progress_alert_days': 3,
|
||||||
|
'max_retries': 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_settings(env, teacher_id=None, entity_id=None):
|
||||||
|
"""Get adaptive settings for teacher/entity or defaults."""
|
||||||
|
Settings = env['encoach.adaptive.settings'].sudo()
|
||||||
|
settings = None
|
||||||
|
if teacher_id:
|
||||||
|
settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1)
|
||||||
|
if not settings and entity_id:
|
||||||
|
settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1)
|
||||||
|
|
||||||
|
if settings:
|
||||||
|
return {
|
||||||
|
'step_up_threshold': settings.step_up_threshold,
|
||||||
|
'step_down_threshold': settings.step_down_threshold,
|
||||||
|
'micro_lesson_trigger': settings.micro_lesson_trigger,
|
||||||
|
'module_skip_threshold': settings.module_skip_threshold,
|
||||||
|
'no_progress_alert_days': settings.no_progress_alert_days,
|
||||||
|
'max_retries': settings.max_retries,
|
||||||
|
}
|
||||||
|
return dict(AdaptiveEngine.DEFAULT_SETTINGS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_checkpoint(env, student_id, course_id, module_id, score, settings=None):
|
||||||
|
"""Process a module checkpoint and make adaptive decisions."""
|
||||||
|
if not settings:
|
||||||
|
settings = AdaptiveEngine.DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
Event = env['encoach.adaptive.event'].sudo()
|
||||||
|
Module = env['encoach.course.module'].sudo()
|
||||||
|
module = Module.browse(module_id)
|
||||||
|
|
||||||
|
decision = None
|
||||||
|
signals = []
|
||||||
|
|
||||||
|
signals.append({
|
||||||
|
'signal_name': 'checkpoint_score',
|
||||||
|
'signal_value': score,
|
||||||
|
})
|
||||||
|
|
||||||
|
if score >= settings['step_up_threshold']:
|
||||||
|
decision = 'step_up'
|
||||||
|
module.write({'status': 'completed'})
|
||||||
|
next_module = Module.search([
|
||||||
|
('course_id', '=', course_id),
|
||||||
|
('sequence', '>', module.sequence),
|
||||||
|
('status', '=', 'locked'),
|
||||||
|
], limit=1, order='sequence')
|
||||||
|
if next_module:
|
||||||
|
next_module.write({'status': 'available'})
|
||||||
|
|
||||||
|
elif score < settings['step_down_threshold']:
|
||||||
|
decision = 'step_down'
|
||||||
|
|
||||||
|
else:
|
||||||
|
decision = 'continue'
|
||||||
|
module.write({'status': 'completed'})
|
||||||
|
next_module = Module.search([
|
||||||
|
('course_id', '=', course_id),
|
||||||
|
('sequence', '>', module.sequence),
|
||||||
|
('status', '=', 'locked'),
|
||||||
|
], limit=1, order='sequence')
|
||||||
|
if next_module:
|
||||||
|
next_module.write({'status': 'available'})
|
||||||
|
|
||||||
|
if score >= settings['module_skip_threshold']:
|
||||||
|
skip_modules = Module.search([
|
||||||
|
('course_id', '=', course_id),
|
||||||
|
('sequence', '>', module.sequence),
|
||||||
|
('status', '=', 'locked'),
|
||||||
|
], limit=2, order='sequence')
|
||||||
|
for sm in skip_modules:
|
||||||
|
sm.write({'status': 'skipped'})
|
||||||
|
if skip_modules:
|
||||||
|
decision = 'skip_ahead'
|
||||||
|
signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)})
|
||||||
|
|
||||||
|
for sig in signals:
|
||||||
|
Event.create({
|
||||||
|
'student_id': student_id,
|
||||||
|
'course_id': course_id,
|
||||||
|
'event_type': 'signal',
|
||||||
|
'signal_name': sig['signal_name'],
|
||||||
|
'signal_value': sig['signal_value'],
|
||||||
|
})
|
||||||
|
|
||||||
|
Event.create({
|
||||||
|
'student_id': student_id,
|
||||||
|
'course_id': course_id,
|
||||||
|
'event_type': 'decision',
|
||||||
|
'decision': decision,
|
||||||
|
'context': json.dumps({'module_id': module_id, 'score': score}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'decision': decision,
|
||||||
|
'score': score,
|
||||||
|
'signals': signals,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_no_progress(env, student_id, course_id, settings=None):
|
||||||
|
"""Phase 4: Check if student has stalled."""
|
||||||
|
if not settings:
|
||||||
|
settings = AdaptiveEngine.DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days'])
|
||||||
|
|
||||||
|
Event = env['encoach.adaptive.event'].sudo()
|
||||||
|
recent_events = Event.search_count([
|
||||||
|
('student_id', '=', student_id),
|
||||||
|
('course_id', '=', course_id),
|
||||||
|
('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')),
|
||||||
|
])
|
||||||
|
|
||||||
|
if recent_events == 0:
|
||||||
|
Event.create({
|
||||||
|
'student_id': student_id,
|
||||||
|
'course_id': course_id,
|
||||||
|
'event_type': 'signal',
|
||||||
|
'signal_name': 'no_progress_alert',
|
||||||
|
'signal_value': settings['no_progress_alert_days'],
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo import fields
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveAlertService:
|
||||||
|
"""Checks for students with no learning progress and creates teacher alerts."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_no_progress(cls, env):
|
||||||
|
"""Find students with no adaptive events within threshold days and alert teachers.
|
||||||
|
|
||||||
|
Called by scheduled action (ir.cron).
|
||||||
|
"""
|
||||||
|
Settings = env['encoach.adaptive.settings'].sudo()
|
||||||
|
Event = env['encoach.adaptive.event'].sudo()
|
||||||
|
Path = env['encoach.adaptive.path'].sudo()
|
||||||
|
Activity = env['mail.activity'].sudo()
|
||||||
|
|
||||||
|
all_settings = Settings.search([])
|
||||||
|
if not all_settings:
|
||||||
|
all_settings = Settings.new({'no_progress_alert_days': 3})
|
||||||
|
|
||||||
|
for setting in all_settings:
|
||||||
|
days = setting.no_progress_alert_days or 3
|
||||||
|
cutoff = fields.Datetime.now() - timedelta(days=days)
|
||||||
|
teacher = setting.teacher_id
|
||||||
|
|
||||||
|
if not teacher:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find students with active paths but no recent events
|
||||||
|
paths = Path.search([])
|
||||||
|
for path in paths:
|
||||||
|
student_id = path.student_id.id
|
||||||
|
recent_events = Event.search_count([
|
||||||
|
('student_id', '=', student_id),
|
||||||
|
('created_at', '>=', cutoff),
|
||||||
|
])
|
||||||
|
|
||||||
|
if recent_events == 0:
|
||||||
|
# Check if alert already exists for this student
|
||||||
|
existing = Activity.search([
|
||||||
|
('res_model', '=', 'res.users'),
|
||||||
|
('res_id', '=', student_id),
|
||||||
|
('user_id', '=', teacher.id),
|
||||||
|
('summary', 'ilike', 'No learning progress'),
|
||||||
|
('date_deadline', '>=', fields.Date.today()),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
activity_type = env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
|
||||||
|
Activity.create({
|
||||||
|
'res_model_id': env['ir.model']._get_id('res.users'),
|
||||||
|
'res_id': student_id,
|
||||||
|
'user_id': teacher.id,
|
||||||
|
'activity_type_id': activity_type.id if activity_type else False,
|
||||||
|
'summary': f'No learning progress for {days}+ days',
|
||||||
|
'note': f'Student {path.student_id.name} has not shown any '
|
||||||
|
f'learning activity in the last {days} days.',
|
||||||
|
'date_deadline': fields.Date.today(),
|
||||||
|
})
|
||||||
|
_logger.info('Created no-progress alert for student %s → teacher %s',
|
||||||
|
path.student_id.name, teacher.name)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STYLE_RESOURCE_MAP = {
|
||||||
|
'visual': ['video', 'pdf', 'interactive'],
|
||||||
|
'auditory': ['video', 'interactive'],
|
||||||
|
'reading': ['pdf', 'document'],
|
||||||
|
'kinesthetic': ['interactive'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StyleMatcher:
|
||||||
|
"""Ranks learning resources based on student's learning style preference."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rank_resources(cls, learning_style, resources):
|
||||||
|
"""Sort resources so that types matching the student's style come first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
learning_style: str, one of visual/auditory/reading/kinesthetic
|
||||||
|
resources: recordset of encoach.resource
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
sorted list of resource records
|
||||||
|
"""
|
||||||
|
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
|
||||||
|
return sorted(resources, key=lambda r: (r.type not in preferred_types, r.id))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_by_style(cls, learning_style, resources):
|
||||||
|
"""Return only resources matching the learning style (with fallback to all)."""
|
||||||
|
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
|
||||||
|
matched = [r for r in resources if r.type in preferred_types]
|
||||||
|
return matched if matched else list(resources)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
.o_signal_timeline .st-timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-timeline::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 19px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #dee2e6;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: -48px;
|
||||||
|
top: 8px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 1;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-timeline-content {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-timeline-content .card {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-timeline-content .card:hover {
|
||||||
|
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-student-card {
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .st-student-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
.o_signal_timeline .cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { Layout } from "@web/search/layout";
|
||||||
|
|
||||||
|
class SignalTimeline extends Component {
|
||||||
|
static template = "encoach_adaptive.SignalTimeline";
|
||||||
|
static components = { Layout };
|
||||||
|
static props = ["*"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
studentId: null,
|
||||||
|
student: null,
|
||||||
|
events: [],
|
||||||
|
filteredEvents: [],
|
||||||
|
filters: { eventType: "", dateFrom: "", dateTo: "" },
|
||||||
|
totalCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
const ctx = this.props.action?.context || {};
|
||||||
|
this.state.studentId = ctx.student_id || ctx.active_id || null;
|
||||||
|
if (this.state.studentId) {
|
||||||
|
await this.loadTimeline();
|
||||||
|
} else {
|
||||||
|
await this.loadStudentList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadStudentList() {
|
||||||
|
try {
|
||||||
|
this.state.studentList = await this.orm.searchRead(
|
||||||
|
"res.users",
|
||||||
|
[["account_source", "!=", false]],
|
||||||
|
["name", "email"],
|
||||||
|
{ limit: 50, order: "name" },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load students:", e);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectStudent(studentId) {
|
||||||
|
this.state.studentId = studentId;
|
||||||
|
this.state.loading = true;
|
||||||
|
await this.loadTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTimeline() {
|
||||||
|
this.state.loading = true;
|
||||||
|
try {
|
||||||
|
const domain = [["student_id", "=", this.state.studentId]];
|
||||||
|
if (this.state.filters.eventType) {
|
||||||
|
domain.push(["event_type", "=", this.state.filters.eventType]);
|
||||||
|
}
|
||||||
|
if (this.state.filters.dateFrom) {
|
||||||
|
domain.push(["created_at", ">=", this.state.filters.dateFrom]);
|
||||||
|
}
|
||||||
|
if (this.state.filters.dateTo) {
|
||||||
|
domain.push(["created_at", "<=", this.state.filters.dateTo + " 23:59:59"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [events, students] = await Promise.all([
|
||||||
|
this.orm.searchRead(
|
||||||
|
"encoach.adaptive.event",
|
||||||
|
domain,
|
||||||
|
["student_id", "course_id", "event_type", "signal_name", "signal_value", "decision", "context", "created_at"],
|
||||||
|
{ limit: 100, order: "created_at desc" },
|
||||||
|
),
|
||||||
|
this.orm.searchRead(
|
||||||
|
"res.users",
|
||||||
|
[["id", "=", this.state.studentId]],
|
||||||
|
["name", "email"],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.state.student = students.length ? students[0] : null;
|
||||||
|
this.state.events = events;
|
||||||
|
this.state.filteredEvents = events;
|
||||||
|
this.state.totalCount = events.length;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Timeline load error:", e);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(field, ev) {
|
||||||
|
this.state.filters[field] = ev.target.value;
|
||||||
|
if (this.state.studentId) {
|
||||||
|
this.loadTimeline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
|
||||||
|
if (this.state.studentId) {
|
||||||
|
this.loadTimeline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventIcon(eventType) {
|
||||||
|
return eventType === "signal" ? "fa-bolt" : "fa-gavel";
|
||||||
|
}
|
||||||
|
|
||||||
|
eventColor(eventType) {
|
||||||
|
return eventType === "signal" ? "primary" : "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dt) {
|
||||||
|
if (!dt) return "";
|
||||||
|
return dt.replace("T", " ").substring(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseContext(ctx) {
|
||||||
|
if (!ctx) return null;
|
||||||
|
try {
|
||||||
|
return typeof ctx === "string" ? JSON.parse(ctx) : ctx;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get signalCount() {
|
||||||
|
return this.state.filteredEvents.filter(e => e.event_type === "signal").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get decisionCount() {
|
||||||
|
return this.state.filteredEvents.filter(e => e.event_type === "decision").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
this.state.studentId = null;
|
||||||
|
this.state.student = null;
|
||||||
|
this.state.events = [];
|
||||||
|
this.state.filteredEvents = [];
|
||||||
|
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
|
||||||
|
this.state.loading = true;
|
||||||
|
this.loadStudentList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("encoach_signal_timeline", SignalTimeline);
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="encoach_adaptive.SignalTimeline">
|
||||||
|
<Layout display="{ controlPanel: {} }">
|
||||||
|
<div class="o_signal_timeline">
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
|
||||||
|
<t t-if="state.loading">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||||
|
<p class="mt-2 text-muted">Loading timeline...</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Student Selector -->
|
||||||
|
<t t-elif="!state.studentId and state.studentList">
|
||||||
|
<h2 class="mb-3">Adaptive Signal Timeline</h2>
|
||||||
|
<p class="text-muted mb-4">Select a student to view their adaptive learning events.</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
|
||||||
|
<div class="col-lg-3 col-md-4 col-sm-6">
|
||||||
|
<div class="card shadow-sm h-100 cursor-pointer st-student-card"
|
||||||
|
t-on-click="() => this.selectStudent(st.id)">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-info bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
|
||||||
|
<i class="fa fa-user text-info"/>
|
||||||
|
</div>
|
||||||
|
<h6 class="card-title mb-0" t-esc="st.name"/>
|
||||||
|
<small class="text-muted" t-esc="st.email"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Timeline View -->
|
||||||
|
<t t-elif="state.student">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
|
||||||
|
t-if="!this.props.action?.context?.student_id">
|
||||||
|
<i class="fa fa-arrow-left me-1"/> Back
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0">Adaptive Timeline</h2>
|
||||||
|
<small class="text-muted">
|
||||||
|
Student: <strong t-esc="state.student.name"/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small fw-bold mb-1">Event Type</label>
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
t-on-change="(ev) => this.onFilterChange('eventType', ev)">
|
||||||
|
<option value="">All Events</option>
|
||||||
|
<option value="signal">Signals</option>
|
||||||
|
<option value="decision">Decisions</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small fw-bold mb-1">From Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm"
|
||||||
|
t-on-change="(ev) => this.onFilterChange('dateFrom', ev)"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small fw-bold mb-1">To Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm"
|
||||||
|
t-on-change="(ev) => this.onFilterChange('dateTo', ev)"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" t-on-click="clearFilters">
|
||||||
|
<i class="fa fa-refresh me-1"/> Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Badges -->
|
||||||
|
<div class="d-flex gap-3 mb-4">
|
||||||
|
<div class="bg-light rounded px-3 py-2 d-flex align-items-center">
|
||||||
|
<i class="fa fa-list me-2 text-secondary"/>
|
||||||
|
<strong class="me-1" t-esc="state.totalCount"/> events
|
||||||
|
</div>
|
||||||
|
<div class="bg-primary bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
|
||||||
|
<i class="fa fa-bolt me-2 text-primary"/>
|
||||||
|
<strong class="me-1" t-esc="signalCount"/> signals
|
||||||
|
</div>
|
||||||
|
<div class="bg-success bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
|
||||||
|
<i class="fa fa-gavel me-2 text-success"/>
|
||||||
|
<strong class="me-1" t-esc="decisionCount"/> decisions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="st-timeline">
|
||||||
|
<t t-foreach="state.filteredEvents" t-as="ev" t-key="ev.id">
|
||||||
|
<div class="st-timeline-item">
|
||||||
|
<div class="st-timeline-marker"
|
||||||
|
t-att-class="'bg-' + eventColor(ev.event_type)">
|
||||||
|
<i class="fa" t-att-class="eventIcon(ev.event_type)"/>
|
||||||
|
</div>
|
||||||
|
<div class="st-timeline-content">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||||
|
<div>
|
||||||
|
<span class="badge me-1"
|
||||||
|
t-att-class="'bg-' + eventColor(ev.event_type)"
|
||||||
|
t-esc="ev.event_type"/>
|
||||||
|
<strong t-if="ev.signal_name" t-esc="ev.signal_name"/>
|
||||||
|
<strong t-elif="ev.decision">Decision</strong>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted" t-esc="formatDate(ev.created_at)"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signal details -->
|
||||||
|
<div t-if="ev.event_type === 'signal'" class="mt-1">
|
||||||
|
<span class="text-muted small">Value: </span>
|
||||||
|
<span class="fw-bold" t-esc="ev.signal_value"/>
|
||||||
|
<t t-if="ev.course_id">
|
||||||
|
<span class="text-muted small ms-3">Course: </span>
|
||||||
|
<span t-esc="ev.course_id[1]"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decision details -->
|
||||||
|
<div t-if="ev.event_type === 'decision'" class="mt-1">
|
||||||
|
<span class="small" t-esc="ev.decision"/>
|
||||||
|
<t t-if="ev.course_id">
|
||||||
|
<span class="text-muted small ms-3">Course: </span>
|
||||||
|
<span t-esc="ev.course_id[1]"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context (if any) -->
|
||||||
|
<div t-if="parseContext(ev.context)" class="mt-1">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
<t t-foreach="Object.entries(parseContext(ev.context) || {})" t-as="entry" t-key="entry[0]">
|
||||||
|
<span class="me-2">
|
||||||
|
<span class="fw-bold" t-esc="entry[0]"/>: <t t-esc="entry[1]"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="!state.filteredEvents.length">
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="fa fa-clock-o fa-3x mb-2 d-block"/>
|
||||||
|
<p>No adaptive events found for this student.</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="!state.studentId">
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="fa fa-clock-o fa-3x mb-3 d-block"/>
|
||||||
|
<p>No student selected. Open from a student record or select one above.</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_adaptive_event_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.adaptive.event.form</field>
|
||||||
|
<field name="model">encoach.adaptive.event</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Adaptive Event">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="event_type"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="signal_name"/>
|
||||||
|
<field name="signal_value"/>
|
||||||
|
<field name="decision"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="context"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_adaptive_event_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.adaptive.event.list</field>
|
||||||
|
<field name="model">encoach.adaptive.event</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Adaptive Events">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="event_type" widget="badge"/>
|
||||||
|
<field name="signal_name"/>
|
||||||
|
<field name="signal_value"/>
|
||||||
|
<field name="decision"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_adaptive_event_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.adaptive.event.search</field>
|
||||||
|
<field name="model">encoach.adaptive.event</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Adaptive Events">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Signals" name="signal" domain="[('event_type', '=', 'signal')]"/>
|
||||||
|
<filter string="Decisions" name="decision" domain="[('event_type', '=', 'decision')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_adaptive_event" model="ir.actions.act_window">
|
||||||
|
<field name="name">Adaptive Events</field>
|
||||||
|
<field name="res_model">encoach.adaptive.event</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<menuitem id="menu_adaptive_events"
|
||||||
|
name="Adaptive Events"
|
||||||
|
parent="encoach_core.menu_encoach_config"
|
||||||
|
action="encoach_adaptive.action_adaptive_event"
|
||||||
|
sequence="60"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_adaptive_paths"
|
||||||
|
name="Adaptive Paths"
|
||||||
|
parent="encoach_core.menu_encoach_config"
|
||||||
|
action="encoach_adaptive.action_adaptive_path"
|
||||||
|
sequence="70"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_adaptive_settings"
|
||||||
|
name="Adaptive Settings"
|
||||||
|
parent="encoach_core.menu_encoach_config"
|
||||||
|
action="encoach_adaptive.action_adaptive_settings"
|
||||||
|
sequence="80"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_adaptive_path_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.adaptive.path.form</field>
|
||||||
|
<field name="model">encoach.adaptive.path</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Adaptive Path">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="source"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="module_queue"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="next_generation_brief"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_adaptive_path_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.adaptive.path.list</field>
|
||||||
|
<field name="model">encoach.adaptive.path</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Adaptive Paths">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="source"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_adaptive_path" model="ir.actions.act_window">
|
||||||
|
<field name="name">Adaptive Paths</field>
|
||||||
|
<field name="res_model">encoach.adaptive.path</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_adaptive_settings_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.adaptive.settings.form</field>
|
||||||
|
<field name="model">encoach.adaptive.settings</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Adaptive Settings">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="teacher_id"/>
|
||||||
|
<field name="entity_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="step_up_threshold"/>
|
||||||
|
<field name="step_down_threshold"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="micro_lesson_trigger"/>
|
||||||
|
<field name="module_skip_threshold"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="no_progress_alert_days"/>
|
||||||
|
<field name="max_retries"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_adaptive_settings_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.adaptive.settings.list</field>
|
||||||
|
<field name="model">encoach.adaptive.settings</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Adaptive Settings">
|
||||||
|
<field name="teacher_id"/>
|
||||||
|
<field name="entity_id"/>
|
||||||
|
<field name="step_up_threshold"/>
|
||||||
|
<field name="step_down_threshold"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_adaptive_settings" model="ir.actions.act_window">
|
||||||
|
<field name="name">Adaptive Settings</field>
|
||||||
|
<field name="res_model">encoach.adaptive.settings</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="action_signal_timeline" model="ir.actions.client">
|
||||||
|
<field name="name">Signal Timeline</field>
|
||||||
|
<field name="tag">encoach_signal_timeline</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_signal_timeline"
|
||||||
|
name="Signal Timeline"
|
||||||
|
parent="encoach_core.menu_encoach_config"
|
||||||
|
action="action_signal_timeline"
|
||||||
|
sequence="55"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
3
new_project/custom_addons/encoach_ai_course/__init__.py
Normal file
3
new_project/custom_addons/encoach_ai_course/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import models
|
||||||
|
from . import services
|
||||||
|
from . import controllers
|
||||||
20
new_project/custom_addons/encoach_ai_course/__manifest__.py
Normal file
20
new_project/custom_addons/encoach_ai_course/__manifest__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
'name': 'EnCoach AI Course',
|
||||||
|
'version': '19.0.1.0',
|
||||||
|
'category': 'Education',
|
||||||
|
'summary': 'AI content generation pipelines for General English and IELTS courses',
|
||||||
|
'author': 'EnCoach',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/ai_generation_log_views.xml',
|
||||||
|
'views/ai_ielts_log_views.xml',
|
||||||
|
'views/ai_course_menus.xml',
|
||||||
|
'views/ai_review_views.xml',
|
||||||
|
'views/ielts_review_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import ai_course
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from odoo import http, fields
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAiCourseController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai-course/english/create
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/english/create', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def create_english(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
cefr_level = body.get('cefr_level')
|
||||||
|
if not cefr_level:
|
||||||
|
return _error_response('cefr_level is required', 400)
|
||||||
|
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
gap_profile_id = body.get('gap_profile_id')
|
||||||
|
|
||||||
|
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||||
|
if gap_profile_id:
|
||||||
|
gap_profile = GapProfile.browse(int(gap_profile_id))
|
||||||
|
if not gap_profile.exists():
|
||||||
|
return _error_response('Gap profile not found', 404)
|
||||||
|
else:
|
||||||
|
gap_profile = GapProfile.search(
|
||||||
|
[('student_id', '=', user.id)],
|
||||||
|
order='created_at desc', limit=1,
|
||||||
|
)
|
||||||
|
if not gap_profile:
|
||||||
|
gap_profile = GapProfile.create({
|
||||||
|
'student_id': user.id,
|
||||||
|
'source_type': 'exam',
|
||||||
|
'skill_gaps': json.dumps([]),
|
||||||
|
'topic_weaknesses': json.dumps([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
brief = {
|
||||||
|
'skill_gaps': json.loads(gap_profile.skill_gaps or '[]'),
|
||||||
|
'topic_weaknesses': json.loads(gap_profile.topic_weaknesses or '[]'),
|
||||||
|
'cefr_level': cefr_level,
|
||||||
|
}
|
||||||
|
|
||||||
|
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||||
|
log = Log.create({
|
||||||
|
'student_id': user.id,
|
||||||
|
'course_type': 'general_english',
|
||||||
|
'status': 'pending_review',
|
||||||
|
'brief': json.dumps(brief),
|
||||||
|
'attempts': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'log_id': log.id,
|
||||||
|
'status': log.status,
|
||||||
|
'brief': brief,
|
||||||
|
}, 201)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('create_english failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai-course/ielts/create
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/ielts/create', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def create_ielts(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
skill = body.get('skill')
|
||||||
|
target_band = body.get('target_band')
|
||||||
|
|
||||||
|
if not skill:
|
||||||
|
return _error_response('skill is required', 400)
|
||||||
|
if not target_band:
|
||||||
|
return _error_response('target_band is required', 400)
|
||||||
|
|
||||||
|
valid_skills = ('listening', 'reading', 'writing', 'speaking')
|
||||||
|
if skill not in valid_skills:
|
||||||
|
return _error_response(
|
||||||
|
f'skill must be one of: {", ".join(valid_skills)}', 400,
|
||||||
|
)
|
||||||
|
|
||||||
|
brief = {
|
||||||
|
'skill': skill,
|
||||||
|
'target_band': float(target_band),
|
||||||
|
'custom_instructions': body.get('brief', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||||
|
log = Log.create({
|
||||||
|
'skill': skill,
|
||||||
|
'status': 'format_check',
|
||||||
|
'brief': json.dumps(brief),
|
||||||
|
'attempts': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'log_id': log.id,
|
||||||
|
'status': log.status,
|
||||||
|
'skill': log.skill,
|
||||||
|
}, 201)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('create_ielts failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai-course/<int:course_id>/quality
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/<int:course_id>/quality', type='http',
|
||||||
|
auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def quality(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||||
|
log = Log.browse(course_id)
|
||||||
|
if not log.exists():
|
||||||
|
return _error_response('Generation log not found', 404)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'status': log.status,
|
||||||
|
'readability_score': 0.0,
|
||||||
|
'cefr_alignment': log.status in ('quality_check', 'approved'),
|
||||||
|
'grammar_issues': [],
|
||||||
|
'attempts': log.attempts,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('quality check failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai-course/<int:course_id>/approve
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/<int:course_id>/approve', type='http',
|
||||||
|
auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def approve(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||||
|
log = Log.browse(course_id)
|
||||||
|
if not log.exists():
|
||||||
|
return _error_response('Generation log not found', 404)
|
||||||
|
|
||||||
|
if log.status == 'approved':
|
||||||
|
return _error_response('Already approved', 400)
|
||||||
|
|
||||||
|
log.write({
|
||||||
|
'status': 'approved',
|
||||||
|
'approved_by': request.env.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'approved': True})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('approve failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai-course/<int:course_id>/reject
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/<int:course_id>/reject', type='http',
|
||||||
|
auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def reject(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
|
||||||
|
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||||
|
log = Log.browse(course_id)
|
||||||
|
if not log.exists():
|
||||||
|
return _error_response('Generation log not found', 404)
|
||||||
|
|
||||||
|
reason = body.get('reason', '')
|
||||||
|
can_retry = log.attempts < 3
|
||||||
|
|
||||||
|
if can_retry:
|
||||||
|
log.write({
|
||||||
|
'status': 'generating',
|
||||||
|
'attempts': log.attempts + 1,
|
||||||
|
'error_log': reason or log.error_log,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
log.write({
|
||||||
|
'status': 'rejected',
|
||||||
|
'error_log': reason or log.error_log,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'rejected': True,
|
||||||
|
'can_retry': can_retry,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('reject failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai-course/<int:course_id>/validation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/<int:course_id>/validation', type='http',
|
||||||
|
auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def validation(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||||
|
ielts_log = IeltsLog.browse(course_id)
|
||||||
|
if ielts_log.exists():
|
||||||
|
fmt_result = {}
|
||||||
|
band_result = {}
|
||||||
|
try:
|
||||||
|
fmt_result = json.loads(ielts_log.format_check_result or '{}')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
band_result = json.loads(ielts_log.band_check_result or '{}')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
overall_passed = bool(
|
||||||
|
ielts_log.format_check_result and ielts_log.band_check_result
|
||||||
|
)
|
||||||
|
return _json_response({
|
||||||
|
'type': 'ielts',
|
||||||
|
'validation_results': {
|
||||||
|
'format_check_result': fmt_result,
|
||||||
|
'band_check_result': band_result,
|
||||||
|
},
|
||||||
|
'overall_passed': overall_passed,
|
||||||
|
})
|
||||||
|
|
||||||
|
Log = request.env['encoach.ai.generation.log'].sudo()
|
||||||
|
log = Log.browse(course_id)
|
||||||
|
if not log.exists():
|
||||||
|
return _error_response('Generation log not found', 404)
|
||||||
|
|
||||||
|
validation_results = {
|
||||||
|
'status': log.status,
|
||||||
|
'quality_passed': log.status in ('approved', 'pending_review'),
|
||||||
|
'attempts': log.attempts,
|
||||||
|
}
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'type': 'english',
|
||||||
|
'validation_results': validation_results,
|
||||||
|
'overall_passed': log.status == 'approved',
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('validation check failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai-course/review-queue
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/review-queue', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def review_queue(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body() if request.httprequest.content_length else {}
|
||||||
|
page = int(kw.get('page', 1))
|
||||||
|
size = int(kw.get('size', 20))
|
||||||
|
|
||||||
|
Resource = request.env['encoach.resource'].sudo()
|
||||||
|
domain = [('ai_generated', '=', True), ('review_status', '=', 'pending')]
|
||||||
|
total = Resource.search_count(domain)
|
||||||
|
resources = Resource.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
'items': [r.to_api_dict() for r in resources],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('review_queue failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai-course/review/<int:resource_id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/review/<int:resource_id>', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def review_resource(self, resource_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
action = body.get('action')
|
||||||
|
notes = body.get('notes', '')
|
||||||
|
|
||||||
|
if action not in ('approve', 'reject'):
|
||||||
|
return _error_response('action must be approve or reject', 400)
|
||||||
|
|
||||||
|
Resource = request.env['encoach.resource'].sudo()
|
||||||
|
resource = Resource.browse(resource_id)
|
||||||
|
if not resource.exists():
|
||||||
|
return _error_response('Resource not found', 404)
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'review_status': 'approved' if action == 'approve' else 'rejected',
|
||||||
|
'approved': action == 'approve',
|
||||||
|
}
|
||||||
|
resource.write(vals)
|
||||||
|
|
||||||
|
return _json_response({'status': vals['review_status'], 'resource_id': resource_id})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('review_resource failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai-course/ielts-review-queue
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/ielts-review-queue', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def ielts_review_queue(self, **kw):
|
||||||
|
try:
|
||||||
|
page = int(kw.get('page', 1))
|
||||||
|
size = int(kw.get('size', 20))
|
||||||
|
|
||||||
|
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||||
|
domain = [('review_status', '=', 'pending_review')]
|
||||||
|
total = Log.search_count(domain)
|
||||||
|
logs = Log.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for log in logs:
|
||||||
|
items.append({
|
||||||
|
'id': log.id,
|
||||||
|
'skill': log.skill or '',
|
||||||
|
'band_target': log.band_target if hasattr(log, 'band_target') else 0,
|
||||||
|
'review_status': log.review_status,
|
||||||
|
'created_at': log.create_date.isoformat() if log.create_date else '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
'items': items,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('ielts_review_queue failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai-course/ielts-review/<int:log_id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/ielts-review/<int:log_id>', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def ielts_review(self, log_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
action = body.get('action')
|
||||||
|
examiner_notes = body.get('examiner_notes', '')
|
||||||
|
|
||||||
|
if action not in ('approve', 'reject', 'revise'):
|
||||||
|
return _error_response('action must be approve, reject, or revise', 400)
|
||||||
|
|
||||||
|
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||||
|
log = Log.browse(log_id)
|
||||||
|
if not log.exists():
|
||||||
|
return _error_response('Log not found', 404)
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
'approve': 'approved',
|
||||||
|
'reject': 'rejected',
|
||||||
|
'revise': 'revision_needed',
|
||||||
|
}
|
||||||
|
|
||||||
|
log.write({
|
||||||
|
'review_status': status_map[action],
|
||||||
|
'examiner_id': request.env.user.id,
|
||||||
|
'examiner_notes': examiner_notes,
|
||||||
|
'reviewed_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'status': status_map[action], 'log_id': log_id})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('ielts_review failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from . import ai_generation_log
|
||||||
|
from . import ai_ielts_generation_log
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAiGenerationLog(models.Model):
|
||||||
|
_name = 'encoach.ai.generation.log'
|
||||||
|
_description = 'AI Generation Log'
|
||||||
|
|
||||||
|
student_id = fields.Many2one('res.users', ondelete='set null')
|
||||||
|
course_type = fields.Selection([
|
||||||
|
('general_english', 'General English'),
|
||||||
|
('ielts', 'IELTS'),
|
||||||
|
], required=True)
|
||||||
|
brief = fields.Text()
|
||||||
|
attempts = fields.Integer(default=0)
|
||||||
|
final_resource_id = fields.Integer(string='Final Resource ID')
|
||||||
|
approved_by = fields.Many2one('res.users', ondelete='set null')
|
||||||
|
status = fields.Selection([
|
||||||
|
('generating', 'Generating'),
|
||||||
|
('quality_check', 'Quality Check'),
|
||||||
|
('pending_review', 'Pending Review'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
], default='generating', required=True)
|
||||||
|
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||||
|
error_log = fields.Text()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAiIeltsGenerationLog(models.Model):
|
||||||
|
_name = 'encoach.ai.ielts.generation.log'
|
||||||
|
_description = 'AI IELTS Generation Log'
|
||||||
|
|
||||||
|
skill = fields.Selection([
|
||||||
|
('listening', 'Listening'),
|
||||||
|
('reading', 'Reading'),
|
||||||
|
('writing', 'Writing'),
|
||||||
|
('speaking', 'Speaking'),
|
||||||
|
], required=True)
|
||||||
|
brief = fields.Text()
|
||||||
|
format_check_result = fields.Text()
|
||||||
|
band_check_result = fields.Text()
|
||||||
|
examiner_id = fields.Many2one('res.users', ondelete='set null')
|
||||||
|
status = fields.Selection([
|
||||||
|
('generating', 'Generating'),
|
||||||
|
('format_check', 'Format Check'),
|
||||||
|
('examiner_review', 'Examiner Review'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
], default='generating', required=True)
|
||||||
|
attempts = fields.Integer(default=0)
|
||||||
|
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||||
|
review_status = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('pending_review', 'Pending Review'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
('revision_needed', 'Revision Needed'),
|
||||||
|
], default='draft')
|
||||||
|
examiner_notes = fields.Text()
|
||||||
|
reviewed_at = fields.Datetime()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
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
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
from .english_pipeline import EnglishPipeline
|
||||||
|
from .ielts_pipeline import IeltsPipeline
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
except ImportError:
|
||||||
|
OpenAIService = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||||
|
except ImportError:
|
||||||
|
ContentSourceGate = None
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EnglishPipeline:
|
||||||
|
"""AI content generation pipeline for General English courses."""
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
self.env = env
|
||||||
|
self.ai = OpenAIService(env)
|
||||||
|
|
||||||
|
def generate_content(self, env, gap_profile, cefr_level):
|
||||||
|
"""Generate General English content based on gap analysis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Odoo environment.
|
||||||
|
gap_profile: dict with 'skill_gaps' list describing weak areas.
|
||||||
|
cefr_level: target CEFR level string (e.g. 'B1').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with generated content.
|
||||||
|
"""
|
||||||
|
skill_gaps = gap_profile.get('skill_gaps', [])
|
||||||
|
gaps_description = ', '.join(skill_gaps) if skill_gaps else 'general skills'
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{'role': 'system', 'content': (
|
||||||
|
'You are an expert English language curriculum designer. '
|
||||||
|
'Return JSON with keys: "title", "objectives", "units" '
|
||||||
|
'(each unit has "topic", "grammar_focus", "vocabulary", '
|
||||||
|
'"reading_text", "exercises").'
|
||||||
|
)},
|
||||||
|
{'role': 'user', 'content': (
|
||||||
|
f'Generate a General English course at CEFR {cefr_level} level. '
|
||||||
|
f'Focus on these gap areas: {gaps_description}. '
|
||||||
|
f'Include practical, real-world content appropriate for the level.'
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = self.ai.chat_json(messages, temperature=0.8)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("English content generation failed: %s", e)
|
||||||
|
env['encoach.ai.generation.log'].create({
|
||||||
|
'course_type': 'general_english',
|
||||||
|
'brief': json.dumps(gap_profile),
|
||||||
|
'status': 'rejected',
|
||||||
|
'error_log': str(e),
|
||||||
|
})
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
resource = env['encoach.ai.generation.log'].create({
|
||||||
|
'course_type': 'general_english',
|
||||||
|
'brief': json.dumps(gap_profile),
|
||||||
|
'status': 'quality_check',
|
||||||
|
'attempts': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Apply content source gate logic
|
||||||
|
if ContentSourceGate is not None:
|
||||||
|
try:
|
||||||
|
ContentSourceGate.apply_gate(resource)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def quality_check(self, env, content, cefr_level):
|
||||||
|
"""Run quality gate checks on generated content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Odoo environment.
|
||||||
|
content: dict of generated content to validate.
|
||||||
|
cefr_level: target CEFR level for alignment check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'passed' bool and 'issues' list.
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
if not content.get('units'):
|
||||||
|
issues.append('No units generated')
|
||||||
|
else:
|
||||||
|
for i, unit in enumerate(content['units'], 1):
|
||||||
|
if not unit.get('exercises'):
|
||||||
|
issues.append(f'Unit {i} has no exercises')
|
||||||
|
if not unit.get('reading_text'):
|
||||||
|
issues.append(f'Unit {i} has no reading text')
|
||||||
|
|
||||||
|
if not content.get('objectives'):
|
||||||
|
issues.append('No learning objectives defined')
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{'role': 'system', 'content': (
|
||||||
|
'You are a CEFR alignment expert. Return JSON with keys: '
|
||||||
|
'"aligned" (bool) and "issues" (list of strings).'
|
||||||
|
)},
|
||||||
|
{'role': 'user', 'content': (
|
||||||
|
f'Check if this content is aligned with CEFR {cefr_level}: '
|
||||||
|
f'{json.dumps(content)}'
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
alignment = self.ai.chat_json(messages, temperature=0.3)
|
||||||
|
if not alignment.get('aligned'):
|
||||||
|
issues.extend(alignment.get('issues', ['CEFR misalignment detected']))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("CEFR alignment check failed: %s", e)
|
||||||
|
issues.append(f'CEFR alignment check error: {e}')
|
||||||
|
|
||||||
|
return {'passed': len(issues) == 0, 'issues': issues}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
except ImportError:
|
||||||
|
OpenAIService = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||||
|
except ImportError:
|
||||||
|
ContentSourceGate = None
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SKILL_PROMPTS = {
|
||||||
|
'listening': (
|
||||||
|
'Generate an IELTS Listening section. Return JSON with keys: '
|
||||||
|
'"script", "speakers", "duration_estimate", "questions".'
|
||||||
|
),
|
||||||
|
'reading': (
|
||||||
|
'Generate an IELTS Reading passage with questions. Return JSON with keys: '
|
||||||
|
'"passage", "word_count", "questions", "question_types".'
|
||||||
|
),
|
||||||
|
'writing': (
|
||||||
|
'Generate an IELTS Writing task. Return JSON with keys: '
|
||||||
|
'"task_type", "prompt", "requirements", "sample_band_descriptors".'
|
||||||
|
),
|
||||||
|
'speaking': (
|
||||||
|
'Generate an IELTS Speaking part. Return JSON with keys: '
|
||||||
|
'"part", "questions", "cue_card" (if part 2), "follow_up_questions".'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
IELTS_FORMAT_RULES = {
|
||||||
|
'listening': {'required_keys': ['script', 'questions'], 'min_questions': 10},
|
||||||
|
'reading': {'required_keys': ['passage', 'questions'], 'min_word_count': 500},
|
||||||
|
'writing': {'required_keys': ['prompt', 'requirements']},
|
||||||
|
'speaking': {'required_keys': ['questions']},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IeltsPipeline:
|
||||||
|
"""AI content generation pipeline for IELTS skill-specific content."""
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
self.env = env
|
||||||
|
self.ai = OpenAIService(env)
|
||||||
|
|
||||||
|
def generate_content(self, env, skill, brief):
|
||||||
|
"""Generate IELTS skill-specific content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Odoo environment.
|
||||||
|
skill: one of 'listening', 'reading', 'writing', 'speaking'.
|
||||||
|
brief: dict with generation parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with generated content.
|
||||||
|
"""
|
||||||
|
system_prompt = SKILL_PROMPTS.get(skill, SKILL_PROMPTS['reading'])
|
||||||
|
brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{'role': 'system', 'content': (
|
||||||
|
f'You are an expert IELTS content creator. {system_prompt}'
|
||||||
|
)},
|
||||||
|
{'role': 'user', 'content': (
|
||||||
|
f'Generate IELTS {skill} content based on this brief: {brief_text}'
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = self.ai.chat_json(messages, temperature=0.8)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("IELTS %s content generation failed: %s", skill, e)
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
resource = env['encoach.ai.ielts.generation.log'].create({
|
||||||
|
'skill': skill,
|
||||||
|
'brief': brief_text,
|
||||||
|
'status': 'format_check',
|
||||||
|
'attempts': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Apply content source gate logic
|
||||||
|
if ContentSourceGate is not None:
|
||||||
|
try:
|
||||||
|
ContentSourceGate.apply_gate(resource)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def format_check(self, env, content, skill):
|
||||||
|
"""Validate IELTS format compliance for a given skill.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Odoo environment.
|
||||||
|
content: dict of generated content.
|
||||||
|
skill: IELTS skill type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'passed' bool and 'errors' list.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
rules = IELTS_FORMAT_RULES.get(skill, {})
|
||||||
|
|
||||||
|
for key in rules.get('required_keys', []):
|
||||||
|
if key not in content or not content[key]:
|
||||||
|
errors.append(f'Missing required key: {key}')
|
||||||
|
|
||||||
|
if skill == 'listening':
|
||||||
|
questions = content.get('questions', [])
|
||||||
|
min_q = rules.get('min_questions', 10)
|
||||||
|
if len(questions) < min_q:
|
||||||
|
errors.append(
|
||||||
|
f'Listening requires at least {min_q} questions, '
|
||||||
|
f'got {len(questions)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if skill == 'reading':
|
||||||
|
passage = content.get('passage', '')
|
||||||
|
min_wc = rules.get('min_word_count', 500)
|
||||||
|
word_count = len(passage.split()) if passage else 0
|
||||||
|
if word_count < min_wc:
|
||||||
|
errors.append(
|
||||||
|
f'Reading passage must be at least {min_wc} words, '
|
||||||
|
f'got {word_count}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if skill == 'speaking' and not content.get('questions'):
|
||||||
|
errors.append('Speaking section requires at least one question')
|
||||||
|
|
||||||
|
return {'passed': len(errors) == 0, 'errors': errors}
|
||||||
|
|
||||||
|
def band_calibration(self, env, content, target_band):
|
||||||
|
"""Check content aligns with target IELTS band level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Odoo environment.
|
||||||
|
content: dict of generated content.
|
||||||
|
target_band: float target band score (e.g. 6.5).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'passed' bool and 'issues' list.
|
||||||
|
"""
|
||||||
|
messages = [
|
||||||
|
{'role': 'system', 'content': (
|
||||||
|
'You are an IELTS band calibration expert. Evaluate whether '
|
||||||
|
'the provided content matches the target band level. '
|
||||||
|
'Return JSON with keys: "calibrated" (bool), "estimated_band" (float), '
|
||||||
|
'"issues" (list of strings).'
|
||||||
|
)},
|
||||||
|
{'role': 'user', 'content': (
|
||||||
|
f'Target band: {target_band}. '
|
||||||
|
f'Content: {json.dumps(content)}'
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.ai.chat_json(messages, temperature=0.3)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Band calibration failed: %s", e)
|
||||||
|
return {'passed': False, 'issues': [f'Band calibration error: {e}']}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'passed': result.get('calibrated', False),
|
||||||
|
'issues': result.get('issues', []),
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<menuitem id="menu_ai_generation_logs"
|
||||||
|
name="AI Generation Logs"
|
||||||
|
parent="encoach_core.menu_encoach_courses"
|
||||||
|
action="encoach_ai_course.action_ai_generation_log"
|
||||||
|
sequence="30"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ai_ielts_logs"
|
||||||
|
name="IELTS Generation Logs"
|
||||||
|
parent="encoach_core.menu_encoach_courses"
|
||||||
|
action="encoach_ai_course.action_ai_ielts_log"
|
||||||
|
sequence="40"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_ai_generation_log_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.generation.log.form</field>
|
||||||
|
<field name="model">encoach.ai.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="AI Generation Log">
|
||||||
|
<header>
|
||||||
|
<field name="status" widget="statusbar" statusbar_visible="generating,quality_check,pending_review,approved"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="course_type"/>
|
||||||
|
<field name="attempts"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="final_resource_id"/>
|
||||||
|
<field name="approved_by"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Brief" name="brief">
|
||||||
|
<field name="brief"/>
|
||||||
|
</page>
|
||||||
|
<page string="Error Log" name="error_log">
|
||||||
|
<field name="error_log"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ai_generation_log_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.generation.log.list</field>
|
||||||
|
<field name="model">encoach.ai.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="AI Generation Logs">
|
||||||
|
<field name="course_type"/>
|
||||||
|
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'pending_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
|
||||||
|
<field name="attempts"/>
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ai_generation_log_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.generation.log.search</field>
|
||||||
|
<field name="model">encoach.ai.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search AI Generation Logs">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Generating" name="generating" domain="[('status', '=', 'generating')]"/>
|
||||||
|
<filter string="Quality Check" name="quality_check" domain="[('status', '=', 'quality_check')]"/>
|
||||||
|
<filter string="Pending Review" name="pending_review" domain="[('status', '=', 'pending_review')]"/>
|
||||||
|
<filter string="Approved" name="approved" domain="[('status', '=', 'approved')]"/>
|
||||||
|
<filter string="Rejected" name="rejected" domain="[('status', '=', 'rejected')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="General English" name="general_english" domain="[('course_type', '=', 'general_english')]"/>
|
||||||
|
<filter string="IELTS" name="ielts" domain="[('course_type', '=', 'ielts')]"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ai_generation_log" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Generation Logs</field>
|
||||||
|
<field name="res_model">encoach.ai.generation.log</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_ai_ielts_log_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.ielts.generation.log.form</field>
|
||||||
|
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="IELTS Generation Log">
|
||||||
|
<header>
|
||||||
|
<field name="status" widget="statusbar" statusbar_visible="generating,format_check,examiner_review,approved"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="skill"/>
|
||||||
|
<field name="attempts"/>
|
||||||
|
<field name="examiner_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Brief" name="brief">
|
||||||
|
<field name="brief"/>
|
||||||
|
</page>
|
||||||
|
<page string="Format Check Result" name="format_check">
|
||||||
|
<field name="format_check_result"/>
|
||||||
|
</page>
|
||||||
|
<page string="Band Check Result" name="band_check">
|
||||||
|
<field name="band_check_result"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ai_ielts_log_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.ielts.generation.log.list</field>
|
||||||
|
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="IELTS Generation Logs">
|
||||||
|
<field name="skill"/>
|
||||||
|
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'examiner_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
|
||||||
|
<field name="attempts"/>
|
||||||
|
<field name="examiner_id"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ai_ielts_log" model="ir.actions.act_window">
|
||||||
|
<field name="name">IELTS Generation Logs</field>
|
||||||
|
<field name="res_model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_ai_review_queue_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.resource.ai.review.list</field>
|
||||||
|
<field name="model">encoach.resource</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="AI Content Review Queue">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="type"/>
|
||||||
|
<field name="cefr_level"/>
|
||||||
|
<field name="grammar_topic"/>
|
||||||
|
<field name="review_status" widget="badge" decoration-info="review_status == 'pending'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'"/>
|
||||||
|
<field name="create_date"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ai_review_queue" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Content Review</field>
|
||||||
|
<field name="res_model">encoach.resource</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="domain">[('ai_generated', '=', True)]</field>
|
||||||
|
<field name="context">{'search_default_pending_review': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_ielts_review_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.ielts.generation.log.review.form</field>
|
||||||
|
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="IELTS Content Review">
|
||||||
|
<header>
|
||||||
|
<field name="review_status" widget="statusbar" statusbar_visible="draft,pending_review,approved"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="skill" readonly="1"/>
|
||||||
|
<field name="review_status"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="examiner_id"/>
|
||||||
|
<field name="reviewed_at"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Examiner Notes">
|
||||||
|
<field name="examiner_notes" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ielts_review_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.ielts.generation.log.review.list</field>
|
||||||
|
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="IELTS Content Review Queue">
|
||||||
|
<field name="skill"/>
|
||||||
|
<field name="review_status" widget="badge" decoration-info="review_status == 'pending_review'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'" decoration-warning="review_status == 'revision_needed'"/>
|
||||||
|
<field name="examiner_id"/>
|
||||||
|
<field name="reviewed_at"/>
|
||||||
|
<field name="create_date"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ielts_review_queue" model="ir.actions.act_window">
|
||||||
|
<field name="name">IELTS Content Review</field>
|
||||||
|
<field name="res_model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="domain">[('review_status', '\!=', 'draft')]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
1
new_project/custom_addons/encoach_api/__init__.py
Normal file
1
new_project/custom_addons/encoach_api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import controllers
|
||||||
11
new_project/custom_addons/encoach_api/__manifest__.py
Normal file
11
new_project/custom_addons/encoach_api/__manifest__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
'name': 'EnCoach API Base',
|
||||||
|
'version': '19.0.2.0.0',
|
||||||
|
'category': 'Education',
|
||||||
|
'summary': 'Base controller utilities (JWT auth, response helpers) for EnCoach REST API',
|
||||||
|
'author': 'EnCoach',
|
||||||
|
'depends': ['base', 'encoach_core'],
|
||||||
|
'data': [],
|
||||||
|
'installable': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import base
|
||||||
153
new_project/custom_addons/encoach_api/controllers/base.py
Normal file
153
new_project/custom_addons/encoach_api/controllers/base.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
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]
|
||||||
2
new_project/custom_addons/encoach_branding/__init__.py
Normal file
2
new_project/custom_addons/encoach_branding/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
15
new_project/custom_addons/encoach_branding/__manifest__.py
Normal file
15
new_project/custom_addons/encoach_branding/__manifest__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
'name': 'EnCoach Branding',
|
||||||
|
'version': '19.0.1.0',
|
||||||
|
'category': 'Education',
|
||||||
|
'summary': 'Whitelabeling and custom branding per entity',
|
||||||
|
'author': 'EnCoach',
|
||||||
|
'depends': ['encoach_core'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/branding_views.xml',
|
||||||
|
'views/branding_menus.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import branding
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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,
|
||||||
|
validate_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachBrandingController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/entity/<int:entity_id>/level-mapping
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/<int:entity_id>/level-mapping', type='http',
|
||||||
|
auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_level_mapping(self, entity_id, **kw):
|
||||||
|
try:
|
||||||
|
Entity = request.env['encoach.entity'].sudo()
|
||||||
|
entity = Entity.browse(entity_id)
|
||||||
|
if not entity.exists():
|
||||||
|
return _error_response('Entity not found', 404)
|
||||||
|
|
||||||
|
Mapping = request.env['encoach.entity.level.mapping'].sudo()
|
||||||
|
mappings = Mapping.search(
|
||||||
|
[('entity_id', '=', entity_id)],
|
||||||
|
order='min_score asc',
|
||||||
|
)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for m in mappings:
|
||||||
|
items.append({
|
||||||
|
'id': m.id,
|
||||||
|
'entity_id': m.entity_id.id,
|
||||||
|
'min_score': m.min_score,
|
||||||
|
'max_score': m.max_score,
|
||||||
|
'internal_level_name': m.internal_level_name,
|
||||||
|
'cefr_equivalent': m.cefr_equivalent or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'mappings': items})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get_level_mapping failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PUT /api/entity/<int:entity_id>/level-mapping
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/<int:entity_id>/level-mapping', type='http',
|
||||||
|
auth='none', methods=['PUT'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def update_level_mapping(self, entity_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
mappings_data = body.get('mappings', [])
|
||||||
|
if not mappings_data:
|
||||||
|
return _error_response('mappings list is required', 400)
|
||||||
|
|
||||||
|
Entity = request.env['encoach.entity'].sudo()
|
||||||
|
entity = Entity.browse(entity_id)
|
||||||
|
if not entity.exists():
|
||||||
|
return _error_response('Entity not found', 404)
|
||||||
|
|
||||||
|
Mapping = request.env['encoach.entity.level.mapping'].sudo()
|
||||||
|
Mapping.search([('entity_id', '=', entity_id)]).unlink()
|
||||||
|
|
||||||
|
new_mappings = []
|
||||||
|
for md in mappings_data:
|
||||||
|
if not md.get('internal_level_name'):
|
||||||
|
return _error_response('internal_level_name is required for each mapping', 400)
|
||||||
|
|
||||||
|
rec = Mapping.create({
|
||||||
|
'entity_id': entity_id,
|
||||||
|
'min_score': float(md.get('min_score', 0)),
|
||||||
|
'max_score': float(md.get('max_score', 0)),
|
||||||
|
'internal_level_name': md['internal_level_name'],
|
||||||
|
'cefr_equivalent': md.get('cefr_equivalent') or False,
|
||||||
|
})
|
||||||
|
new_mappings.append({
|
||||||
|
'id': rec.id,
|
||||||
|
'entity_id': rec.entity_id.id,
|
||||||
|
'min_score': rec.min_score,
|
||||||
|
'max_score': rec.max_score,
|
||||||
|
'internal_level_name': rec.internal_level_name,
|
||||||
|
'cefr_equivalent': rec.cefr_equivalent or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'mappings': new_mappings})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('update_level_mapping failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/entity/<int:entity_id>/branding
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/<int:entity_id>/branding', type='http',
|
||||||
|
auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_entity_branding(self, entity_id, **kw):
|
||||||
|
try:
|
||||||
|
Branding = request.env['encoach.branding'].sudo()
|
||||||
|
branding = Branding.search([('entity_id', '=', entity_id)], limit=1)
|
||||||
|
if not branding:
|
||||||
|
return _error_response('Branding not found for this entity', 404)
|
||||||
|
|
||||||
|
return _json_response(branding.to_api_dict())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get_entity_branding failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PUT /api/entity/<int:entity_id>/branding
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/<int:entity_id>/branding', type='http',
|
||||||
|
auth='none', methods=['PUT'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def update_entity_branding(self, entity_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
|
||||||
|
Entity = request.env['encoach.entity'].sudo()
|
||||||
|
entity = Entity.browse(entity_id)
|
||||||
|
if not entity.exists():
|
||||||
|
return _error_response('Entity not found', 404)
|
||||||
|
|
||||||
|
Branding = request.env['encoach.branding'].sudo()
|
||||||
|
branding = Branding.search([('entity_id', '=', entity_id)], limit=1)
|
||||||
|
|
||||||
|
allowed = [
|
||||||
|
'primary_color', 'secondary_color', 'background_color',
|
||||||
|
'app_name', 'login_title', 'login_description',
|
||||||
|
'white_label_domain', 'custom_css',
|
||||||
|
]
|
||||||
|
vals = {k: body[k] for k in allowed if k in body}
|
||||||
|
|
||||||
|
if not branding:
|
||||||
|
vals['entity_id'] = entity_id
|
||||||
|
branding = Branding.create(vals)
|
||||||
|
else:
|
||||||
|
branding.write(vals)
|
||||||
|
|
||||||
|
return _json_response(branding.to_api_dict())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('update_entity_branding failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/entity/branding (combined public + authenticated)
|
||||||
|
# GET /api/entity/branding/public
|
||||||
|
#
|
||||||
|
# If ?domain=<subdomain> is present → public lookup, no auth needed.
|
||||||
|
# Otherwise → JWT required, return user entity branding.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
['/api/entity/branding', '/api/entity/branding/public'],
|
||||||
|
type='http', auth='none', methods=['GET'], csrf=False,
|
||||||
|
)
|
||||||
|
def get_branding(self, **kw):
|
||||||
|
try:
|
||||||
|
domain_param = kw.get('domain')
|
||||||
|
|
||||||
|
if domain_param:
|
||||||
|
Branding = request.env(su=True)['encoach.branding']
|
||||||
|
branding = Branding.search(
|
||||||
|
[('white_label_domain', '=', domain_param)], limit=1,
|
||||||
|
)
|
||||||
|
if not branding:
|
||||||
|
return _error_response('Branding not found for this domain', 404)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'app_name': branding.app_name or 'EnCoach',
|
||||||
|
'primary_color': branding.primary_color or '#4F46E5',
|
||||||
|
'secondary_color': branding.secondary_color or '#7C3AED',
|
||||||
|
'background_color': branding.background_color or '#FFFFFF',
|
||||||
|
'has_logo': bool(branding.logo),
|
||||||
|
'logo_url': branding.logo_url or '',
|
||||||
|
'login_title': branding.login_title or '',
|
||||||
|
'login_description': branding.login_description or '',
|
||||||
|
'custom_css': branding.custom_css or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
user = validate_token()
|
||||||
|
if not user:
|
||||||
|
return _error_response('Missing or invalid Authorization header', 401)
|
||||||
|
entity = user.entity_ids[:1]
|
||||||
|
if not entity:
|
||||||
|
return _error_response('User has no associated entity', 404)
|
||||||
|
|
||||||
|
Branding = request.env['encoach.branding'].sudo()
|
||||||
|
branding = Branding.search([('entity_id', '=', entity.id)], limit=1)
|
||||||
|
if not branding:
|
||||||
|
return _json_response({
|
||||||
|
'entity_id': entity.id,
|
||||||
|
'entity_name': entity.name,
|
||||||
|
'app_name': 'EnCoach',
|
||||||
|
'primary_color': entity.primary_color or '#4F46E5',
|
||||||
|
'secondary_color': entity.secondary_color or '#7C3AED',
|
||||||
|
'background_color': entity.background_color or '#FFFFFF',
|
||||||
|
'has_logo': bool(entity.logo),
|
||||||
|
'logo_url': entity.logo_url or '',
|
||||||
|
'login_title': entity.login_title or '',
|
||||||
|
'login_description': entity.login_description or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response(branding.to_api_dict())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get_branding failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import branding
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachBranding(models.Model):
|
||||||
|
_name = 'encoach.branding'
|
||||||
|
_description = 'Entity Branding'
|
||||||
|
|
||||||
|
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade')
|
||||||
|
primary_color = fields.Char(default='#4F46E5')
|
||||||
|
secondary_color = fields.Char(default='#7C3AED')
|
||||||
|
background_color = fields.Char(default='#FFFFFF')
|
||||||
|
logo = fields.Binary(attachment=True)
|
||||||
|
logo_url = fields.Char(string='Logo URL')
|
||||||
|
favicon = fields.Binary(attachment=True)
|
||||||
|
app_name = fields.Char(default='EnCoach')
|
||||||
|
white_label_domain = fields.Char(string='Subdomain')
|
||||||
|
login_title = fields.Char(default='Welcome to EnCoach')
|
||||||
|
login_description = fields.Text()
|
||||||
|
custom_css = fields.Text()
|
||||||
|
|
||||||
|
_entity_unique = models.Constraint('UNIQUE(entity_id)', 'Branding record must be unique per entity.')
|
||||||
|
|
||||||
|
def to_api_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'entity_id': self.entity_id.id,
|
||||||
|
'entity_name': self.entity_id.name,
|
||||||
|
'primary_color': self.primary_color,
|
||||||
|
'secondary_color': self.secondary_color,
|
||||||
|
'background_color': self.background_color or '#FFFFFF',
|
||||||
|
'has_logo': bool(self.logo),
|
||||||
|
'logo_url': self.logo_url or '',
|
||||||
|
'has_favicon': bool(self.favicon),
|
||||||
|
'app_name': self.app_name,
|
||||||
|
'white_label_domain': self.white_label_domain or '',
|
||||||
|
'login_title': self.login_title or '',
|
||||||
|
'login_description': self.login_description or '',
|
||||||
|
'custom_css': self.custom_css or '',
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_encoach_branding_all,encoach.branding.all,model_encoach_branding,base.group_user,1,1,1,1
|
||||||
|
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<menuitem id="menu_branding"
|
||||||
|
name="White-Label Branding"
|
||||||
|
parent="encoach_core.menu_encoach_entity"
|
||||||
|
action="encoach_branding.action_branding"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_branding_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.branding.form</field>
|
||||||
|
<field name="model">encoach.branding</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Branding">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="entity_id"/>
|
||||||
|
<field name="app_name"/>
|
||||||
|
<field name="white_label_domain"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="logo" widget="image" class="oe_avatar"/>
|
||||||
|
<field name="logo_url"/>
|
||||||
|
<field name="favicon" widget="image" class="oe_avatar"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Colors">
|
||||||
|
<group>
|
||||||
|
<field name="primary_color" widget="color"/>
|
||||||
|
<field name="secondary_color" widget="color"/>
|
||||||
|
<field name="background_color" widget="color"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Login Page">
|
||||||
|
<field name="login_title"/>
|
||||||
|
<field name="login_description"/>
|
||||||
|
</group>
|
||||||
|
<group string="Custom CSS">
|
||||||
|
<field name="custom_css"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_branding_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.branding.list</field>
|
||||||
|
<field name="model">encoach.branding</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Branding">
|
||||||
|
<field name="entity_id"/>
|
||||||
|
<field name="app_name"/>
|
||||||
|
<field name="primary_color"/>
|
||||||
|
<field name="secondary_color"/>
|
||||||
|
<field name="white_label_domain"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_branding" model="ir.actions.act_window">
|
||||||
|
<field name="name">White-Label Branding</field>
|
||||||
|
<field name="res_model">encoach.branding</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
1
new_project/custom_addons/encoach_core/__init__.py
Normal file
1
new_project/custom_addons/encoach_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
27
new_project/custom_addons/encoach_core/__manifest__.py
Normal file
27
new_project/custom_addons/encoach_core/__manifest__.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
'name': 'EnCoach Core',
|
||||||
|
'version': '19.0.1.1',
|
||||||
|
'category': 'Education',
|
||||||
|
'summary': 'Core models for EnCoach: users, entities, roles, permissions',
|
||||||
|
'author': 'EnCoach',
|
||||||
|
'depends': ['base', 'mail', 'openeducat_core'],
|
||||||
|
'data': [
|
||||||
|
'security/encoach_security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/encoach_permissions.xml',
|
||||||
|
'views/encoach_menus.xml',
|
||||||
|
'views/encoach_entity_views.xml',
|
||||||
|
'views/entity_level_mapping_views.xml',
|
||||||
|
'views/dashboard_action.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'encoach_core/static/src/js/dashboard.js',
|
||||||
|
'encoach_core/static/src/xml/dashboard.xml',
|
||||||
|
'encoach_core/static/src/css/dashboard.css',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="0">
|
||||||
|
<!-- Global permissions (PermissionType from permissions.ts) -->
|
||||||
|
<record id="perm_viewCorporate" model="encoach.permission">
|
||||||
|
<field name="code">viewCorporate</field>
|
||||||
|
<field name="topic">Manage Corporate</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_editCorporate" model="encoach.permission">
|
||||||
|
<field name="code">editCorporate</field>
|
||||||
|
<field name="topic">Manage Corporate</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_deleteCorporate" model="encoach.permission">
|
||||||
|
<field name="code">deleteCorporate</field>
|
||||||
|
<field name="topic">Manage Corporate</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createCodeCorporate" model="encoach.permission">
|
||||||
|
<field name="code">createCodeCorporate</field>
|
||||||
|
<field name="topic">Manage Corporate</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewAdmin" model="encoach.permission">
|
||||||
|
<field name="code">viewAdmin</field>
|
||||||
|
<field name="topic">Manage Admin</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_editAdmin" model="encoach.permission">
|
||||||
|
<field name="code">editAdmin</field>
|
||||||
|
<field name="topic">Manage Admin</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_deleteAdmin" model="encoach.permission">
|
||||||
|
<field name="code">deleteAdmin</field>
|
||||||
|
<field name="topic">Manage Admin</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createCodeAdmin" model="encoach.permission">
|
||||||
|
<field name="code">createCodeAdmin</field>
|
||||||
|
<field name="topic">Manage Admin</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewStudent" model="encoach.permission">
|
||||||
|
<field name="code">viewStudent</field>
|
||||||
|
<field name="topic">Manage Student</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_editStudent" model="encoach.permission">
|
||||||
|
<field name="code">editStudent</field>
|
||||||
|
<field name="topic">Manage Student</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_deleteStudent" model="encoach.permission">
|
||||||
|
<field name="code">deleteStudent</field>
|
||||||
|
<field name="topic">Manage Student</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createCodeStudent" model="encoach.permission">
|
||||||
|
<field name="code">createCodeStudent</field>
|
||||||
|
<field name="topic">Manage Student</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewTeacher" model="encoach.permission">
|
||||||
|
<field name="code">viewTeacher</field>
|
||||||
|
<field name="topic">Manage Teacher</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_editTeacher" model="encoach.permission">
|
||||||
|
<field name="code">editTeacher</field>
|
||||||
|
<field name="topic">Manage Teacher</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_deleteTeacher" model="encoach.permission">
|
||||||
|
<field name="code">deleteTeacher</field>
|
||||||
|
<field name="topic">Manage Teacher</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createCodeTeacher" model="encoach.permission">
|
||||||
|
<field name="code">createCodeTeacher</field>
|
||||||
|
<field name="topic">Manage Teacher</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewCountryManager" model="encoach.permission">
|
||||||
|
<field name="code">viewCountryManager</field>
|
||||||
|
<field name="topic">Manage Country Manager</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_editCountryManager" model="encoach.permission">
|
||||||
|
<field name="code">editCountryManager</field>
|
||||||
|
<field name="topic">Manage Country Manager</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_deleteCountryManager" model="encoach.permission">
|
||||||
|
<field name="code">deleteCountryManager</field>
|
||||||
|
<field name="topic">Manage Country Manager</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createCodeCountryManager" model="encoach.permission">
|
||||||
|
<field name="code">createCodeCountryManager</field>
|
||||||
|
<field name="topic">Manage Country Manager</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createReadingExam" model="encoach.permission">
|
||||||
|
<field name="code">createReadingExam</field>
|
||||||
|
<field name="topic">Manage Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createListeningExam" model="encoach.permission">
|
||||||
|
<field name="code">createListeningExam</field>
|
||||||
|
<field name="topic">Manage Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createWritingExam" model="encoach.permission">
|
||||||
|
<field name="code">createWritingExam</field>
|
||||||
|
<field name="topic">Manage Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createSpeakingExam" model="encoach.permission">
|
||||||
|
<field name="code">createSpeakingExam</field>
|
||||||
|
<field name="topic">Manage Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createLevelExam" model="encoach.permission">
|
||||||
|
<field name="code">createLevelExam</field>
|
||||||
|
<field name="topic">Manage Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewExams" model="encoach.permission">
|
||||||
|
<field name="code">viewExams</field>
|
||||||
|
<field name="topic">View Pages</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewExercises" model="encoach.permission">
|
||||||
|
<field name="code">viewExercises</field>
|
||||||
|
<field name="topic">View Pages</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewRecords" model="encoach.permission">
|
||||||
|
<field name="code">viewRecords</field>
|
||||||
|
<field name="topic">View Pages</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewStats" model="encoach.permission">
|
||||||
|
<field name="code">viewStats</field>
|
||||||
|
<field name="topic">View Pages</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewTickets" model="encoach.permission">
|
||||||
|
<field name="code">viewTickets</field>
|
||||||
|
<field name="topic">View Pages</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewPaymentRecords" model="encoach.permission">
|
||||||
|
<field name="code">viewPaymentRecords</field>
|
||||||
|
<field name="topic">View Pages</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewGroup" model="encoach.permission">
|
||||||
|
<field name="code">viewGroup</field>
|
||||||
|
<field name="topic">Manage Group</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_editGroup" model="encoach.permission">
|
||||||
|
<field name="code">editGroup</field>
|
||||||
|
<field name="topic">Manage Group</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_deleteGroup" model="encoach.permission">
|
||||||
|
<field name="code">deleteGroup</field>
|
||||||
|
<field name="topic">Manage Group</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createGroup" model="encoach.permission">
|
||||||
|
<field name="code">createGroup</field>
|
||||||
|
<field name="topic">Manage Group</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_viewCodes" model="encoach.permission">
|
||||||
|
<field name="code">viewCodes</field>
|
||||||
|
<field name="topic">Manage Codes</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_deleteCodes" model="encoach.permission">
|
||||||
|
<field name="code">deleteCodes</field>
|
||||||
|
<field name="topic">Manage Codes</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_createCodes" model="encoach.permission">
|
||||||
|
<field name="code">createCodes</field>
|
||||||
|
<field name="topic">Manage Codes</field>
|
||||||
|
</record>
|
||||||
|
<record id="perm_all" model="encoach.permission">
|
||||||
|
<field name="code">all</field>
|
||||||
|
<field name="topic">Others</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Role-level permissions (RolePermission from entityPermissions.ts) -->
|
||||||
|
<record id="rperm_view_students" model="encoach.permission">
|
||||||
|
<field name="code">view_students</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_teachers" model="encoach.permission">
|
||||||
|
<field name="code">view_teachers</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_corporates" model="encoach.permission">
|
||||||
|
<field name="code">view_corporates</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_mastercorporates" model="encoach.permission">
|
||||||
|
<field name="code">view_mastercorporates</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_students" model="encoach.permission">
|
||||||
|
<field name="code">edit_students</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_teachers" model="encoach.permission">
|
||||||
|
<field name="code">edit_teachers</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_corporates" model="encoach.permission">
|
||||||
|
<field name="code">edit_corporates</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_mastercorporates" model="encoach.permission">
|
||||||
|
<field name="code">edit_mastercorporates</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_students" model="encoach.permission">
|
||||||
|
<field name="code">delete_students</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_teachers" model="encoach.permission">
|
||||||
|
<field name="code">delete_teachers</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_corporates" model="encoach.permission">
|
||||||
|
<field name="code">delete_corporates</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_mastercorporates" model="encoach.permission">
|
||||||
|
<field name="code">delete_mastercorporates</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_generate_reading" model="encoach.permission">
|
||||||
|
<field name="code">generate_reading</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_reading" model="encoach.permission">
|
||||||
|
<field name="code">view_reading</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_reading" model="encoach.permission">
|
||||||
|
<field name="code">delete_reading</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_generate_listening" model="encoach.permission">
|
||||||
|
<field name="code">generate_listening</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_listening" model="encoach.permission">
|
||||||
|
<field name="code">view_listening</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_listening" model="encoach.permission">
|
||||||
|
<field name="code">delete_listening</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_generate_writing" model="encoach.permission">
|
||||||
|
<field name="code">generate_writing</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_writing" model="encoach.permission">
|
||||||
|
<field name="code">view_writing</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_writing" model="encoach.permission">
|
||||||
|
<field name="code">delete_writing</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_generate_speaking" model="encoach.permission">
|
||||||
|
<field name="code">generate_speaking</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_speaking" model="encoach.permission">
|
||||||
|
<field name="code">view_speaking</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_speaking" model="encoach.permission">
|
||||||
|
<field name="code">delete_speaking</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_generate_level" model="encoach.permission">
|
||||||
|
<field name="code">generate_level</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_level" model="encoach.permission">
|
||||||
|
<field name="code">view_level</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_level" model="encoach.permission">
|
||||||
|
<field name="code">delete_level</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_classrooms" model="encoach.permission">
|
||||||
|
<field name="code">view_classrooms</field>
|
||||||
|
<field name="topic">Classrooms</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_classroom" model="encoach.permission">
|
||||||
|
<field name="code">create_classroom</field>
|
||||||
|
<field name="topic">Classrooms</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_rename_classrooms" model="encoach.permission">
|
||||||
|
<field name="code">rename_classrooms</field>
|
||||||
|
<field name="topic">Classrooms</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_add_to_classroom" model="encoach.permission">
|
||||||
|
<field name="code">add_to_classroom</field>
|
||||||
|
<field name="topic">Classrooms</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_remove_from_classroom" model="encoach.permission">
|
||||||
|
<field name="code">remove_from_classroom</field>
|
||||||
|
<field name="topic">Classrooms</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_classroom" model="encoach.permission">
|
||||||
|
<field name="code">delete_classroom</field>
|
||||||
|
<field name="topic">Classrooms</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_entities" model="encoach.permission">
|
||||||
|
<field name="code">view_entities</field>
|
||||||
|
<field name="topic">Entities</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_rename_entity" model="encoach.permission">
|
||||||
|
<field name="code">rename_entity</field>
|
||||||
|
<field name="topic">Entities</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_add_to_entity" model="encoach.permission">
|
||||||
|
<field name="code">add_to_entity</field>
|
||||||
|
<field name="topic">Entities</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_remove_from_entity" model="encoach.permission">
|
||||||
|
<field name="code">remove_from_entity</field>
|
||||||
|
<field name="topic">Entities</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_entity" model="encoach.permission">
|
||||||
|
<field name="code">delete_entity</field>
|
||||||
|
<field name="topic">Entities</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_entity_roles" model="encoach.permission">
|
||||||
|
<field name="code">view_entity_roles</field>
|
||||||
|
<field name="topic">Roles</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_entity_role" model="encoach.permission">
|
||||||
|
<field name="code">create_entity_role</field>
|
||||||
|
<field name="topic">Roles</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_rename_entity_role" model="encoach.permission">
|
||||||
|
<field name="code">rename_entity_role</field>
|
||||||
|
<field name="topic">Roles</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_role_permissions" model="encoach.permission">
|
||||||
|
<field name="code">edit_role_permissions</field>
|
||||||
|
<field name="topic">Roles</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_assign_to_role" model="encoach.permission">
|
||||||
|
<field name="code">assign_to_role</field>
|
||||||
|
<field name="topic">Roles</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_entity_role" model="encoach.permission">
|
||||||
|
<field name="code">delete_entity_role</field>
|
||||||
|
<field name="topic">Roles</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_assignments" model="encoach.permission">
|
||||||
|
<field name="code">view_assignments</field>
|
||||||
|
<field name="topic">Assignments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_assignment" model="encoach.permission">
|
||||||
|
<field name="code">create_assignment</field>
|
||||||
|
<field name="topic">Assignments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_assignment" model="encoach.permission">
|
||||||
|
<field name="code">edit_assignment</field>
|
||||||
|
<field name="topic">Assignments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_assignment" model="encoach.permission">
|
||||||
|
<field name="code">delete_assignment</field>
|
||||||
|
<field name="topic">Assignments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_start_assignment" model="encoach.permission">
|
||||||
|
<field name="code">start_assignment</field>
|
||||||
|
<field name="topic">Assignments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_archive_assignment" model="encoach.permission">
|
||||||
|
<field name="code">archive_assignment</field>
|
||||||
|
<field name="topic">Assignments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_entity_statistics" model="encoach.permission">
|
||||||
|
<field name="code">view_entity_statistics</field>
|
||||||
|
<field name="topic">Statistics</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_user" model="encoach.permission">
|
||||||
|
<field name="code">create_user</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_user_batch" model="encoach.permission">
|
||||||
|
<field name="code">create_user_batch</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_code" model="encoach.permission">
|
||||||
|
<field name="code">create_code</field>
|
||||||
|
<field name="topic">Codes</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_code_batch" model="encoach.permission">
|
||||||
|
<field name="code">create_code_batch</field>
|
||||||
|
<field name="topic">Codes</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_code_list" model="encoach.permission">
|
||||||
|
<field name="code">view_code_list</field>
|
||||||
|
<field name="topic">Codes</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_code" model="encoach.permission">
|
||||||
|
<field name="code">delete_code</field>
|
||||||
|
<field name="topic">Codes</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_statistics" model="encoach.permission">
|
||||||
|
<field name="code">view_statistics</field>
|
||||||
|
<field name="topic">Statistics</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_download_statistics_report" model="encoach.permission">
|
||||||
|
<field name="code">download_statistics_report</field>
|
||||||
|
<field name="topic">Statistics</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_grading_system" model="encoach.permission">
|
||||||
|
<field name="code">edit_grading_system</field>
|
||||||
|
<field name="topic">Grading</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_student_performance" model="encoach.permission">
|
||||||
|
<field name="code">view_student_performance</field>
|
||||||
|
<field name="topic">Students</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_upload_classroom" model="encoach.permission">
|
||||||
|
<field name="code">upload_classroom</field>
|
||||||
|
<field name="topic">Classrooms</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_download_user_list" model="encoach.permission">
|
||||||
|
<field name="code">download_user_list</field>
|
||||||
|
<field name="topic">Users</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_student_record" model="encoach.permission">
|
||||||
|
<field name="code">view_student_record</field>
|
||||||
|
<field name="topic">Students</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_download_student_record" model="encoach.permission">
|
||||||
|
<field name="code">download_student_record</field>
|
||||||
|
<field name="topic">Students</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_pay_entity" model="encoach.permission">
|
||||||
|
<field name="code">pay_entity</field>
|
||||||
|
<field name="topic">Payments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_payment_record" model="encoach.permission">
|
||||||
|
<field name="code">view_payment_record</field>
|
||||||
|
<field name="topic">Payments</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_approval_workflows" model="encoach.permission">
|
||||||
|
<field name="code">view_approval_workflows</field>
|
||||||
|
<field name="topic">Workflows</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_update_exam_privacy" model="encoach.permission">
|
||||||
|
<field name="code">update_exam_privacy</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_workflows" model="encoach.permission">
|
||||||
|
<field name="code">view_workflows</field>
|
||||||
|
<field name="topic">Workflows</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_configure_workflows" model="encoach.permission">
|
||||||
|
<field name="code">configure_workflows</field>
|
||||||
|
<field name="topic">Workflows</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_edit_workflow" model="encoach.permission">
|
||||||
|
<field name="code">edit_workflow</field>
|
||||||
|
<field name="topic">Workflows</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_delete_workflow" model="encoach.permission">
|
||||||
|
<field name="code">delete_workflow</field>
|
||||||
|
<field name="topic">Workflows</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_view_confidential_exams" model="encoach.permission">
|
||||||
|
<field name="code">view_confidential_exams</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_confidential_exams" model="encoach.permission">
|
||||||
|
<field name="code">create_confidential_exams</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
<record id="rperm_create_public_exams" model="encoach.permission">
|
||||||
|
<field name="code">create_public_exams</field>
|
||||||
|
<field name="topic">Exams</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from . import encoach_user
|
||||||
|
from . import encoach_entity
|
||||||
|
from . import role
|
||||||
|
from . import permission
|
||||||
|
from . import code
|
||||||
|
from . import invite
|
||||||
|
from . import user_entity_rel
|
||||||
|
from . import entity_level_mapping
|
||||||
57
new_project/custom_addons/encoach_core/models/code.py
Normal file
57
new_project/custom_addons/encoach_core/models/code.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import uuid
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachCode(models.Model):
|
||||||
|
_name = "encoach.code"
|
||||||
|
_description = "EnCoach Registration Code"
|
||||||
|
|
||||||
|
code = fields.Char(required=True, index=True, default=lambda self: uuid.uuid4().hex[:8].upper())
|
||||||
|
entity_id = fields.Many2one("encoach.entity", ondelete="set null")
|
||||||
|
creator_id = fields.Many2one("res.users", required=True)
|
||||||
|
expiry_date = fields.Datetime()
|
||||||
|
code_type = fields.Selection(
|
||||||
|
[("individual", "Individual"), ("corporate", "Corporate")],
|
||||||
|
default="individual",
|
||||||
|
)
|
||||||
|
max_uses = fields.Integer(default=1)
|
||||||
|
uses = fields.Integer(default=0)
|
||||||
|
user_type = fields.Selection(
|
||||||
|
[
|
||||||
|
("student", "Student"),
|
||||||
|
("teacher", "Teacher"),
|
||||||
|
("corporate", "Corporate"),
|
||||||
|
],
|
||||||
|
default="student",
|
||||||
|
)
|
||||||
|
legacy_id = fields.Char(index=True)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def validate_code(self, code_str):
|
||||||
|
rec = self.search([("code", "=", code_str)], limit=1)
|
||||||
|
if not rec:
|
||||||
|
return {"valid": False, "error": "Code not found"}
|
||||||
|
if rec.expiry_date and rec.expiry_date < fields.Datetime.now():
|
||||||
|
return {"valid": False, "error": "Code expired"}
|
||||||
|
if rec.max_uses and rec.uses >= rec.max_uses:
|
||||||
|
return {"valid": False, "error": "Code fully used"}
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"code": rec.to_encoach_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_encoach_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"code": self.code,
|
||||||
|
"entity": self.entity_id.id if self.entity_id else None,
|
||||||
|
"creator": self.creator_id.id,
|
||||||
|
"expiryDate": (
|
||||||
|
self.expiry_date.isoformat() if self.expiry_date else None
|
||||||
|
),
|
||||||
|
"type": self.code_type,
|
||||||
|
"maxUses": self.max_uses,
|
||||||
|
"uses": self.uses,
|
||||||
|
"userType": self.user_type,
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachEntity(models.Model):
|
||||||
|
_name = 'encoach.entity'
|
||||||
|
_description = 'EnCoach Entity'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
|
||||||
|
name = fields.Char(required=True, tracking=True)
|
||||||
|
code = fields.Char(required=True, tracking=True)
|
||||||
|
type = fields.Selection([
|
||||||
|
('corporate', 'Corporate'),
|
||||||
|
('university', 'University'),
|
||||||
|
('school', 'School'),
|
||||||
|
('government', 'Government'),
|
||||||
|
('freelance', 'Freelance'),
|
||||||
|
], tracking=True)
|
||||||
|
logo = fields.Binary(attachment=True)
|
||||||
|
logo_url = fields.Char(string='Logo URL')
|
||||||
|
primary_color = fields.Char(default='#4F46E5')
|
||||||
|
secondary_color = fields.Char(default='#7C3AED')
|
||||||
|
background_color = fields.Char(default='#FFFFFF')
|
||||||
|
white_label_domain = fields.Char(string='White Label Domain')
|
||||||
|
login_title = fields.Char()
|
||||||
|
login_description = fields.Text()
|
||||||
|
favicon = fields.Binary(attachment=True)
|
||||||
|
results_release_mode = fields.Selection([
|
||||||
|
('auto', 'Auto'),
|
||||||
|
('manual_approval', 'Manual Approval'),
|
||||||
|
], default='auto')
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
user_ids = fields.Many2many('res.users', 'encoach_entity_user_rel', 'entity_id', 'user_id', string='Users')
|
||||||
|
role_ids = fields.One2many('encoach.role', 'entity_id', string='Roles')
|
||||||
|
user_count = fields.Integer(compute='_compute_user_count', store=True)
|
||||||
|
|
||||||
|
_code_unique = models.Constraint('UNIQUE(code)', 'Entity code must be unique.')
|
||||||
|
|
||||||
|
@api.depends('user_ids')
|
||||||
|
def _compute_user_count(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.user_count = len(rec.user_ids)
|
||||||
|
|
||||||
|
def to_api_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'code': self.code,
|
||||||
|
'type': self.type or '',
|
||||||
|
'logo': bool(self.logo),
|
||||||
|
'logo_url': self.logo_url or '',
|
||||||
|
'primary_color': self.primary_color or '#4F46E5',
|
||||||
|
'secondary_color': self.secondary_color or '#7C3AED',
|
||||||
|
'background_color': self.background_color or '#FFFFFF',
|
||||||
|
'white_label_domain': self.white_label_domain or '',
|
||||||
|
'login_title': self.login_title or '',
|
||||||
|
'login_description': self.login_description or '',
|
||||||
|
'has_favicon': bool(self.favicon),
|
||||||
|
'results_release_mode': self.results_release_mode or 'auto',
|
||||||
|
'active': self.active,
|
||||||
|
'user_count': self.user_count,
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
USER_TYPE_SELECTION = [
|
||||||
|
('student', 'Student'),
|
||||||
|
('teacher', 'Teacher'),
|
||||||
|
('admin', 'Admin'),
|
||||||
|
('corporate', 'Corporate'),
|
||||||
|
('mastercorporate', 'Master Corporate'),
|
||||||
|
('agent', 'Agent'),
|
||||||
|
('developer', 'Developer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachUser(models.Model):
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
user_type = fields.Selection(USER_TYPE_SELECTION, string='User Type')
|
||||||
|
is_verified = fields.Boolean(default=False)
|
||||||
|
entity_ids = fields.Many2many('encoach.entity', 'encoach_entity_user_rel', 'user_id', 'entity_id', string='Entities')
|
||||||
|
phone = fields.Char()
|
||||||
|
birth_date = fields.Date()
|
||||||
|
gender = fields.Selection([('m', 'Male'), ('f', 'Female'), ('o', 'Other')])
|
||||||
|
op_student_id = fields.Many2one('op.student', string='OpenEduCat Student')
|
||||||
|
op_faculty_id = fields.Many2one('op.faculty', string='OpenEduCat Faculty')
|
||||||
|
encoach_avatar = fields.Binary(attachment=True, string='EnCoach Avatar')
|
||||||
|
last_login = fields.Datetime()
|
||||||
|
role_ids = fields.Many2many(
|
||||||
|
'encoach.role', 'encoach_role_user_rel', 'user_id', 'role_id', string='Roles',
|
||||||
|
)
|
||||||
|
permission_direct_ids = fields.Many2many(
|
||||||
|
'encoach.permission', 'encoach_user_permission_rel', 'user_id', 'permission_id',
|
||||||
|
string='Direct Permissions',
|
||||||
|
)
|
||||||
|
|
||||||
|
first_login = fields.Boolean(default=True)
|
||||||
|
account_source = fields.Selection([
|
||||||
|
('self_registered', 'Self Registered'),
|
||||||
|
('entity_bulk_upload', 'Entity Bulk Upload'),
|
||||||
|
])
|
||||||
|
account_status = fields.Selection([
|
||||||
|
('unactivated', 'Unactivated'),
|
||||||
|
('activated', 'Activated'),
|
||||||
|
('suspended', 'Suspended'),
|
||||||
|
], default='unactivated')
|
||||||
|
|
||||||
|
def get_all_permissions(self):
|
||||||
|
"""Return the union of direct permissions and permissions from all assigned roles."""
|
||||||
|
self.ensure_one()
|
||||||
|
role_permissions = self.role_ids.mapped('permission_ids')
|
||||||
|
return self.permission_direct_ids | role_permissions
|
||||||
|
|
||||||
|
def _api_user_type(self):
|
||||||
|
"""Frontend routes require a non-empty role; many users have no encoach.user_type set yet."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.user_type:
|
||||||
|
return self.user_type
|
||||||
|
if self.op_faculty_id:
|
||||||
|
return 'teacher'
|
||||||
|
if self.op_student_id:
|
||||||
|
return 'student'
|
||||||
|
if self.has_group('base.group_system'):
|
||||||
|
return 'admin'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def to_api_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'email': self.email or '',
|
||||||
|
'login': self.login or '',
|
||||||
|
'user_type': self._api_user_type(),
|
||||||
|
'is_verified': self.is_verified,
|
||||||
|
'phone': self.phone or '',
|
||||||
|
'birth_date': self.birth_date.isoformat() if self.birth_date else None,
|
||||||
|
'gender': self.gender or '',
|
||||||
|
'avatar': bool(self.encoach_avatar),
|
||||||
|
'entities': [
|
||||||
|
{'id': e.id, 'name': e.name, 'logo': bool(e.logo)}
|
||||||
|
for e in self.entity_ids
|
||||||
|
],
|
||||||
|
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||||
|
'first_login': self.first_login,
|
||||||
|
'account_source': self.account_source or '',
|
||||||
|
'account_status': self.account_status or 'unactivated',
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachEntityLevelMapping(models.Model):
|
||||||
|
_name = 'encoach.entity.level.mapping'
|
||||||
|
_description = 'Entity Level Mapping'
|
||||||
|
|
||||||
|
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade', index=True)
|
||||||
|
min_score = fields.Float(required=True)
|
||||||
|
max_score = fields.Float(required=True)
|
||||||
|
internal_level_name = fields.Char(size=100, required=True)
|
||||||
|
cefr_equivalent = fields.Selection([
|
||||||
|
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
|
||||||
|
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
|
||||||
|
])
|
||||||
46
new_project/custom_addons/encoach_core/models/invite.py
Normal file
46
new_project/custom_addons/encoach_core/models/invite.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachInvite(models.Model):
|
||||||
|
_name = "encoach.invite"
|
||||||
|
_description = "EnCoach Entity Invite"
|
||||||
|
|
||||||
|
from_user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
|
||||||
|
to_user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
|
||||||
|
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
|
||||||
|
status = fields.Selection(
|
||||||
|
[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("accepted", "Accepted"),
|
||||||
|
("declined", "Declined"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
)
|
||||||
|
legacy_id = fields.Char(index=True)
|
||||||
|
|
||||||
|
def action_accept(self):
|
||||||
|
self.ensure_one()
|
||||||
|
self.status = "accepted"
|
||||||
|
existing = self.env["encoach.user.entity.rel"].search([
|
||||||
|
("user_id", "=", self.to_user_id.id),
|
||||||
|
("entity_id", "=", self.entity_id.id),
|
||||||
|
], limit=1)
|
||||||
|
if not existing:
|
||||||
|
self.env["encoach.user.entity.rel"].create({
|
||||||
|
"user_id": self.to_user_id.id,
|
||||||
|
"entity_id": self.entity_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_decline(self):
|
||||||
|
self.ensure_one()
|
||||||
|
self.status = "declined"
|
||||||
|
|
||||||
|
def to_encoach_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"from": self.from_user_id.id,
|
||||||
|
"to": self.to_user_id.id,
|
||||||
|
"entity": self.entity_id.id,
|
||||||
|
"status": self.status,
|
||||||
|
}
|
||||||
24
new_project/custom_addons/encoach_core/models/permission.py
Normal file
24
new_project/custom_addons/encoach_core/models/permission.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachPermission(models.Model):
|
||||||
|
_name = "encoach.permission"
|
||||||
|
_description = "EnCoach Permission"
|
||||||
|
|
||||||
|
code = fields.Char(required=True, index=True)
|
||||||
|
topic = fields.Char()
|
||||||
|
legacy_id = fields.Char(index=True)
|
||||||
|
|
||||||
|
_code_unique = models.Constraint(
|
||||||
|
"UNIQUE(code)",
|
||||||
|
"Permission code must be unique.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_encoach_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"type": self.code,
|
||||||
|
"topic": self.topic or "",
|
||||||
|
"users": [],
|
||||||
|
}
|
||||||
22
new_project/custom_addons/encoach_core/models/role.py
Normal file
22
new_project/custom_addons/encoach_core/models/role.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachRole(models.Model):
|
||||||
|
_name = "encoach.role"
|
||||||
|
_description = "EnCoach Role"
|
||||||
|
|
||||||
|
name = fields.Char(required=True)
|
||||||
|
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
|
||||||
|
permission_ids = fields.Many2many("encoach.permission", string="Permissions")
|
||||||
|
is_default = fields.Boolean(default=False)
|
||||||
|
legacy_id = fields.Char(index=True)
|
||||||
|
|
||||||
|
def to_encoach_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.name,
|
||||||
|
"entityID": self.entity_id.id,
|
||||||
|
"permissions": [p.code for p in self.permission_ids if p.code],
|
||||||
|
"isDefault": self.is_default,
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachUserEntityRel(models.Model):
|
||||||
|
_name = "encoach.user.entity.rel"
|
||||||
|
_description = "EnCoach User-Entity Membership"
|
||||||
|
|
||||||
|
user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
|
||||||
|
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
|
||||||
|
role_id = fields.Many2one("encoach.role", ondelete="set null")
|
||||||
|
|
||||||
|
_user_entity_unique = models.Constraint(
|
||||||
|
"UNIQUE(user_id, entity_id)",
|
||||||
|
"A user can only have one membership per entity.",
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Security groups matching the 7 user types -->
|
||||||
|
<record id="group_encoach_student" model="res.groups">
|
||||||
|
<field name="name">EnCoach Student</field>
|
||||||
|
</record>
|
||||||
|
<record id="group_encoach_teacher" model="res.groups">
|
||||||
|
<field name="name">EnCoach Teacher</field>
|
||||||
|
</record>
|
||||||
|
<record id="group_encoach_corporate" model="res.groups">
|
||||||
|
<field name="name">EnCoach Corporate</field>
|
||||||
|
</record>
|
||||||
|
<record id="group_encoach_admin" model="res.groups">
|
||||||
|
<field name="name">EnCoach Admin</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_encoach_teacher'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="group_encoach_developer" model="res.groups">
|
||||||
|
<field name="name">EnCoach Developer</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_encoach_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="group_encoach_agent" model="res.groups">
|
||||||
|
<field name="name">EnCoach Agent</field>
|
||||||
|
</record>
|
||||||
|
<record id="group_encoach_mastercorporate" model="res.groups">
|
||||||
|
<field name="name">EnCoach Master Corporate</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_encoach_corporate'))]"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_encoach_entity_student,encoach.entity.student,model_encoach_entity,group_encoach_student,1,0,0,0
|
||||||
|
access_encoach_entity_teacher,encoach.entity.teacher,model_encoach_entity,group_encoach_teacher,1,1,0,0
|
||||||
|
access_encoach_entity_admin,encoach.entity.admin,model_encoach_entity,group_encoach_admin,1,1,1,1
|
||||||
|
access_encoach_role_student,encoach.role.student,model_encoach_role,group_encoach_student,1,0,0,0
|
||||||
|
access_encoach_role_teacher,encoach.role.teacher,model_encoach_role,group_encoach_teacher,1,1,0,0
|
||||||
|
access_encoach_role_admin,encoach.role.admin,model_encoach_role,group_encoach_admin,1,1,1,1
|
||||||
|
access_encoach_permission_student,encoach.permission.student,model_encoach_permission,group_encoach_student,1,0,0,0
|
||||||
|
access_encoach_permission_teacher,encoach.permission.teacher,model_encoach_permission,group_encoach_teacher,1,0,0,0
|
||||||
|
access_encoach_permission_admin,encoach.permission.admin,model_encoach_permission,group_encoach_admin,1,1,1,1
|
||||||
|
access_encoach_invite_student,encoach.invite.student,model_encoach_invite,group_encoach_student,1,0,0,0
|
||||||
|
access_encoach_invite_teacher,encoach.invite.teacher,model_encoach_invite,group_encoach_teacher,1,1,0,0
|
||||||
|
access_encoach_invite_admin,encoach.invite.admin,model_encoach_invite,group_encoach_admin,1,1,1,1
|
||||||
|
access_encoach_code_student,encoach.code.student,model_encoach_code,group_encoach_student,1,0,0,0
|
||||||
|
access_encoach_code_teacher,encoach.code.teacher,model_encoach_code,group_encoach_teacher,1,1,0,0
|
||||||
|
access_encoach_code_admin,encoach.code.admin,model_encoach_code,group_encoach_admin,1,1,1,1
|
||||||
|
access_encoach_user_entity_rel_all,encoach.user.entity.rel.all,model_encoach_user_entity_rel,base.group_user,1,1,1,1
|
||||||
|
access_encoach_level_mapping_student,encoach.entity.level.mapping.student,model_encoach_entity_level_mapping,group_encoach_student,1,0,0,0
|
||||||
|
access_encoach_level_mapping_teacher,encoach.entity.level.mapping.teacher,model_encoach_entity_level_mapping,group_encoach_teacher,1,1,0,0
|
||||||
|
access_encoach_level_mapping_admin,encoach.entity.level.mapping.admin,model_encoach_entity_level_mapping,group_encoach_admin,1,1,1,1
|
||||||
|
@@ -0,0 +1,16 @@
|
|||||||
|
.o_encoach_dashboard .card {
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.o_encoach_dashboard .card.cursor-pointer:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
.o_encoach_dashboard h2 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.o_encoach_dashboard .badge {
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { Layout } from "@web/search/layout";
|
||||||
|
|
||||||
|
class EncoachDashboard extends Component {
|
||||||
|
static template = "encoach_core.Dashboard";
|
||||||
|
static components = { Layout };
|
||||||
|
static props = ["*"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
stats: {
|
||||||
|
total_students: 0,
|
||||||
|
total_teachers: 0,
|
||||||
|
total_courses: 0,
|
||||||
|
total_exams: 0,
|
||||||
|
active_sessions: 0,
|
||||||
|
pending_grading: 0,
|
||||||
|
pending_approval: 0,
|
||||||
|
total_entities: 0,
|
||||||
|
},
|
||||||
|
recentAttempts: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this.loadDashboardData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDashboardData() {
|
||||||
|
try {
|
||||||
|
const [students, teachers, courses, exams, sessions, pendingGrading, pendingApproval, entities] = await Promise.all([
|
||||||
|
this.orm.searchCount("res.users", [["account_source", "!=", false]]),
|
||||||
|
this.orm.searchCount("res.users", [["user_type", "=", "teacher"]]),
|
||||||
|
this.orm.searchCount("op.course", []),
|
||||||
|
this.orm.searchCount("encoach.exam.custom", []),
|
||||||
|
this.orm.searchCount("encoach.student.attempt", [["status", "=", "in_progress"]]),
|
||||||
|
this.orm.searchCount("encoach.student.attempt", [["status", "=", "scoring"]]),
|
||||||
|
this.orm.searchCount("encoach.student.attempt", [["status", "=", "pending_approval"]]),
|
||||||
|
this.orm.searchCount("encoach.entity", []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.state.stats = {
|
||||||
|
total_students: students,
|
||||||
|
total_teachers: teachers,
|
||||||
|
total_courses: courses,
|
||||||
|
total_exams: exams,
|
||||||
|
active_sessions: sessions,
|
||||||
|
pending_grading: pendingGrading,
|
||||||
|
pending_approval: pendingApproval,
|
||||||
|
total_entities: entities,
|
||||||
|
};
|
||||||
|
|
||||||
|
const attempts = await this.orm.searchRead(
|
||||||
|
"encoach.student.attempt",
|
||||||
|
[],
|
||||||
|
["student_id", "exam_id", "status", "overall_band", "started_at"],
|
||||||
|
{ limit: 10, order: "started_at desc" },
|
||||||
|
);
|
||||||
|
this.state.recentAttempts = attempts;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Dashboard load error:", e);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openAction(model, name, domain = []) {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
name: name,
|
||||||
|
res_model: model,
|
||||||
|
views: [[false, "list"], [false, "form"]],
|
||||||
|
domain: domain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openStudents() { this.openAction("res.users", "Students", [["account_source", "!=", false]]); }
|
||||||
|
openTeachers() { this.openAction("res.users", "Teachers", [["user_type", "=", "teacher"]]); }
|
||||||
|
openCourses() { this.openAction("op.course", "Courses"); }
|
||||||
|
openExams() { this.openAction("encoach.exam.custom", "Exams"); }
|
||||||
|
openGradingQueue() { this.openAction("encoach.student.attempt", "Grading Queue", [["status", "=", "scoring"]]); }
|
||||||
|
openPendingApproval() { this.openAction("encoach.student.attempt", "Score Approval", [["status", "=", "pending_approval"]]); }
|
||||||
|
openEntities() { this.openAction("encoach.entity", "Entities"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("encoach_dashboard", EncoachDashboard);
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="encoach_core.Dashboard">
|
||||||
|
<Layout display="{ controlPanel: {} }">
|
||||||
|
<div class="o_encoach_dashboard">
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<h2 class="mb-4">EnCoach Dashboard</h2>
|
||||||
|
|
||||||
|
<t t-if="state.loading">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-3x"/>
|
||||||
|
<p class="mt-2">Loading dashboard...</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-else="">
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openStudents">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="rounded-circle bg-primary bg-opacity-10 p-3 me-3">
|
||||||
|
<i class="fa fa-users fa-2x text-primary"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-0" t-esc="state.stats.total_students"/>
|
||||||
|
<small class="text-muted">Students</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openTeachers">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
|
||||||
|
<i class="fa fa-chalkboard-teacher fa-2x text-success"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-0" t-esc="state.stats.total_teachers"/>
|
||||||
|
<small class="text-muted">Teachers</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openCourses">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="rounded-circle bg-info bg-opacity-10 p-3 me-3">
|
||||||
|
<i class="fa fa-book fa-2x text-info"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-0" t-esc="state.stats.total_courses"/>
|
||||||
|
<small class="text-muted">Courses</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openExams">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="rounded-circle bg-warning bg-opacity-10 p-3 me-3">
|
||||||
|
<i class="fa fa-file-text fa-2x text-warning"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-0" t-esc="state.stats.total_exams"/>
|
||||||
|
<small class="text-muted">Exams</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="card shadow-sm border-start border-danger border-4 cursor-pointer" t-on-click="openGradingQueue">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Grading Queue</h5>
|
||||||
|
<h2 class="text-danger" t-esc="state.stats.pending_grading"/>
|
||||||
|
<small class="text-muted">Exams awaiting grading</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="card shadow-sm border-start border-warning border-4 cursor-pointer" t-on-click="openPendingApproval">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Pending Approval</h5>
|
||||||
|
<h2 class="text-warning" t-esc="state.stats.pending_approval"/>
|
||||||
|
<small class="text-muted">Scores awaiting release</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="card shadow-sm border-start border-info border-4 cursor-pointer" t-on-click="openEntities">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Entities</h5>
|
||||||
|
<h2 class="text-info" t-esc="state.stats.total_entities"/>
|
||||||
|
<small class="text-muted">Registered organizations</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Attempts Table -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">Recent Exam Attempts</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Exam</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Band</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="state.recentAttempts" t-as="attempt" t-key="attempt.id">
|
||||||
|
<tr>
|
||||||
|
<td t-esc="attempt.student_id[1]"/>
|
||||||
|
<td t-esc="attempt.exam_id ? attempt.exam_id[1] : '-'"/>
|
||||||
|
<td>
|
||||||
|
<span class="badge"
|
||||||
|
t-att-class="{
|
||||||
|
'bg-success': attempt.status === 'released',
|
||||||
|
'bg-warning': attempt.status === 'pending_approval',
|
||||||
|
'bg-info': attempt.status === 'scoring',
|
||||||
|
'bg-secondary': attempt.status === 'in_progress',
|
||||||
|
'bg-primary': attempt.status === 'scored'
|
||||||
|
}"
|
||||||
|
t-esc="attempt.status"/>
|
||||||
|
</td>
|
||||||
|
<td t-esc="attempt.overall_band || '-'"/>
|
||||||
|
<td t-esc="attempt.started_at"/>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<t t-if="!state.recentAttempts.length">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted py-4">No recent attempts</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="action_encoach_dashboard" model="ir.actions.client">
|
||||||
|
<field name="name">EnCoach Dashboard</field>
|
||||||
|
<field name="tag">encoach_dashboard</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_encoach_dashboard_action"
|
||||||
|
name="Dashboard"
|
||||||
|
parent="menu_encoach_dashboard"
|
||||||
|
action="action_encoach_dashboard"
|
||||||
|
sequence="1"/>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_encoach_entity_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.entity.list</field>
|
||||||
|
<field name="model">encoach.entity</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Entities">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="type"/>
|
||||||
|
<field name="white_label_domain"/>
|
||||||
|
<field name="user_count"/>
|
||||||
|
<field name="results_release_mode"/>
|
||||||
|
<field name="active" column_invisible="True"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_entity_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.entity.form</field>
|
||||||
|
<field name="model">encoach.entity</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Entity">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
</div>
|
||||||
|
<field name="logo" widget="image" class="oe_avatar"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" placeholder="Entity Name"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="type"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="white_label_domain"/>
|
||||||
|
<field name="results_release_mode"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Branding">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="primary_color" widget="color"/>
|
||||||
|
<field name="secondary_color" widget="color"/>
|
||||||
|
<field name="background_color" widget="color"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="logo_url"/>
|
||||||
|
<field name="login_title"/>
|
||||||
|
<field name="favicon" widget="image"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<field name="login_description"/>
|
||||||
|
</page>
|
||||||
|
<page string="Users">
|
||||||
|
<field name="user_ids">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="login"/>
|
||||||
|
<field name="user_type"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Roles">
|
||||||
|
<field name="role_ids">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_entity_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.entity.search</field>
|
||||||
|
<field name="model">encoach.entity</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Entities">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<filter string="Corporate" name="corporate" domain="[('type', '=', 'corporate')]"/>
|
||||||
|
<filter string="University" name="university" domain="[('type', '=', 'university')]"/>
|
||||||
|
<filter string="School" name="school" domain="[('type', '=', 'school')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_encoach_entity" model="ir.actions.act_window">
|
||||||
|
<field name="name">Entities</field>
|
||||||
|
<field name="res_model">encoach.entity</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_encoach_entity_search"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first entity
|
||||||
|
</p>
|
||||||
|
<p>Entities represent organizations (corporates, universities, schools) on the EnCoach platform.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_encoach_entity_list"
|
||||||
|
name="Entities"
|
||||||
|
parent="menu_encoach_entity"
|
||||||
|
action="action_encoach_entity"
|
||||||
|
sequence="1"/>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_encoach_root" name="EnCoach" sequence="10" web_icon="encoach_core,static/description/icon.png"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_encoach_dashboard" name="Dashboard" parent="menu_encoach_root" sequence="1"/>
|
||||||
|
<menuitem id="menu_encoach_exams" name="Exams" parent="menu_encoach_root" sequence="10"/>
|
||||||
|
<menuitem id="menu_encoach_courses" name="Courses" parent="menu_encoach_root" sequence="20"/>
|
||||||
|
<menuitem id="menu_encoach_students" name="Students" parent="menu_encoach_root" sequence="30"/>
|
||||||
|
<menuitem id="menu_encoach_lms" name="LMS" parent="menu_encoach_root" sequence="40"/>
|
||||||
|
<menuitem id="menu_encoach_content" name="Content" parent="menu_encoach_root" sequence="50"/>
|
||||||
|
<menuitem id="menu_encoach_entity" name="Entity Management" parent="menu_encoach_root" sequence="60"/>
|
||||||
|
<menuitem id="menu_encoach_reports" name="Reports" parent="menu_encoach_root" sequence="70"/>
|
||||||
|
<menuitem id="menu_encoach_config" name="Configuration" parent="menu_encoach_root" sequence="80"/>
|
||||||
|
<menuitem id="menu_encoach_support" name="Support" parent="menu_encoach_root" sequence="90"/>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_entity_level_mapping_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.entity.level.mapping.form</field>
|
||||||
|
<field name="model">encoach.entity.level.mapping</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Entity Level Mapping">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="entity_id"/>
|
||||||
|
<field name="internal_level_name"/>
|
||||||
|
<field name="cefr_equivalent"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="min_score"/>
|
||||||
|
<field name="max_score"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_entity_level_mapping_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.entity.level.mapping.list</field>
|
||||||
|
<field name="model">encoach.entity.level.mapping</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Entity Level Mappings" editable="bottom">
|
||||||
|
<field name="entity_id"/>
|
||||||
|
<field name="internal_level_name"/>
|
||||||
|
<field name="min_score"/>
|
||||||
|
<field name="max_score"/>
|
||||||
|
<field name="cefr_equivalent"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_entity_level_mapping" model="ir.actions.act_window">
|
||||||
|
<field name="name">Entity Level Mappings</field>
|
||||||
|
<field name="res_model">encoach.entity.level.mapping</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_entity_level_mapping"
|
||||||
|
name="Entity Level Mappings"
|
||||||
|
parent="encoach_core.menu_encoach_entity"
|
||||||
|
action="encoach_core.action_entity_level_mapping"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
2
new_project/custom_addons/encoach_course_gen/__init__.py
Normal file
2
new_project/custom_addons/encoach_course_gen/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
27
new_project/custom_addons/encoach_course_gen/__manifest__.py
Normal file
27
new_project/custom_addons/encoach_course_gen/__manifest__.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
'name': 'EnCoach Course Generation',
|
||||||
|
'version': '19.0.1.0',
|
||||||
|
'category': 'Education',
|
||||||
|
'summary': 'Gap analysis, auto course generation, and adaptive progression',
|
||||||
|
'author': 'EnCoach',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'depends': ['encoach_core', 'encoach_taxonomy', 'encoach_resources'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/gap_profile_views.xml',
|
||||||
|
'views/course_section_views.xml',
|
||||||
|
'views/course_module_views.xml',
|
||||||
|
'views/gap_analysis_action.xml',
|
||||||
|
'views/course_gen_menus.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'encoach_course_gen/static/src/js/gap_analysis.js',
|
||||||
|
'encoach_course_gen/static/src/xml/gap_analysis.xml',
|
||||||
|
'encoach_course_gen/static/src/css/gap_analysis.css',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import course
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
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__)
|
||||||
|
|
||||||
|
CEFR_ORDER = ['pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2']
|
||||||
|
|
||||||
|
STEP_DOWN_THRESHOLD = 0.4
|
||||||
|
PASS_THRESHOLD_DEFAULT = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
def _cefr_index(level):
|
||||||
|
try:
|
||||||
|
return CEFR_ORDER.index(level)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _next_cefr(level):
|
||||||
|
idx = _cefr_index(level)
|
||||||
|
return CEFR_ORDER[min(idx + 1, len(CEFR_ORDER) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _module_to_dict(mod):
|
||||||
|
return {
|
||||||
|
'id': mod.id,
|
||||||
|
'name': mod.name,
|
||||||
|
'cefr_target': mod.cefr_target or '',
|
||||||
|
'auto_generated': mod.auto_generated,
|
||||||
|
'completion_criteria': mod.completion_criteria or 'all_resources',
|
||||||
|
'score_threshold': mod.score_threshold,
|
||||||
|
'prerequisite_module_id': mod.prerequisite_module_id.id if mod.prerequisite_module_id else None,
|
||||||
|
'status': mod.status,
|
||||||
|
'sequence': mod.sequence,
|
||||||
|
'generation_brief': mod.generation_brief or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _course_to_dict(course, include_modules=True):
|
||||||
|
data = {
|
||||||
|
'id': course.id,
|
||||||
|
'name': course.name,
|
||||||
|
'code': course.code if hasattr(course, 'code') and course.code else '',
|
||||||
|
'description': course.description if hasattr(course, 'description') else '',
|
||||||
|
'generation_source': course.generation_source if hasattr(course, 'generation_source') else '',
|
||||||
|
'progression_model': course.progression_model if hasattr(course, 'progression_model') else 'linear',
|
||||||
|
'target_band': course.target_band if hasattr(course, 'target_band') else 0.0,
|
||||||
|
'entity_id': course.entity_id.id if hasattr(course, 'entity_id') and course.entity_id else None,
|
||||||
|
}
|
||||||
|
if include_modules:
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
modules = Module.search([('course_id', '=', course.id)], order='sequence asc')
|
||||||
|
data['modules'] = [_module_to_dict(m) for m in modules]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachCourseController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/course/create
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/create', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def create(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
name = (body.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
return _error_response('name is required', 400)
|
||||||
|
|
||||||
|
Course = request.env['op.course'].sudo()
|
||||||
|
course_vals = {
|
||||||
|
'name': name,
|
||||||
|
'code': body.get('code', name[:20].upper().replace(' ', '_')),
|
||||||
|
}
|
||||||
|
if body.get('subject_id'):
|
||||||
|
course_vals['subject_id'] = int(body['subject_id'])
|
||||||
|
if body.get('description') and hasattr(Course, 'description'):
|
||||||
|
course_vals['description'] = body['description']
|
||||||
|
if hasattr(Course, 'generation_source'):
|
||||||
|
course_vals['generation_source'] = 'manual'
|
||||||
|
if hasattr(Course, 'entity_id'):
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
if user.entity_ids:
|
||||||
|
course_vals['entity_id'] = user.entity_ids[0].id
|
||||||
|
|
||||||
|
course = Course.create(course_vals)
|
||||||
|
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
modules_data = body.get('modules', [])
|
||||||
|
for idx, mod_data in enumerate(modules_data):
|
||||||
|
Module.create({
|
||||||
|
'name': mod_data.get('name', f'Module {idx + 1}'),
|
||||||
|
'course_id': course.id,
|
||||||
|
'cefr_target': mod_data.get('cefr_target', ''),
|
||||||
|
'completion_criteria': mod_data.get('completion_criteria', 'all_resources'),
|
||||||
|
'score_threshold': mod_data.get('score_threshold', 0.0),
|
||||||
|
'sequence': mod_data.get('sequence', (idx + 1) * 10),
|
||||||
|
'status': 'available' if idx == 0 else 'locked',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response(_course_to_dict(course), 201)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('course create failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/course/gap-analysis
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/gap-analysis', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def gap_analysis(self, **kw):
|
||||||
|
try:
|
||||||
|
uid = request.env.user.id
|
||||||
|
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||||
|
profile = GapProfile.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
], limit=1, order='created_at desc')
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
CatSession = request.env['encoach.cat.session'].sudo()
|
||||||
|
session = CatSession.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('status', '=', 'completed'),
|
||||||
|
], limit=1, order='completed_at desc')
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
return _error_response('No placement results or gap profile found', 404)
|
||||||
|
|
||||||
|
Ability = request.env['encoach.student.ability.model'].sudo()
|
||||||
|
abilities = Ability.search([('student_id', '=', uid)])
|
||||||
|
|
||||||
|
skill_gaps = {}
|
||||||
|
for ab in abilities:
|
||||||
|
if ab.theta < 0.5:
|
||||||
|
skill_gaps[ab.skill] = {
|
||||||
|
'theta': ab.theta,
|
||||||
|
'sem': ab.sem,
|
||||||
|
'gap_severity': 'high' if ab.theta < -0.5 else 'medium' if ab.theta < 0.0 else 'low',
|
||||||
|
}
|
||||||
|
|
||||||
|
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||||
|
placement_attempt = Attempt.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('is_placement', '=', True),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
question_type_weaknesses = {}
|
||||||
|
topic_weaknesses = {}
|
||||||
|
if placement_attempt:
|
||||||
|
Answer = request.env['encoach.student.answer'].sudo()
|
||||||
|
answers = Answer.search([('attempt_id', '=', placement_attempt.id)])
|
||||||
|
type_stats = {}
|
||||||
|
topic_stats = {}
|
||||||
|
for ans in answers:
|
||||||
|
q = ans.question_id
|
||||||
|
qt = q.question_type or 'unknown'
|
||||||
|
type_stats.setdefault(qt, {'correct': 0, 'total': 0})
|
||||||
|
type_stats[qt]['total'] += 1
|
||||||
|
if ans.is_correct:
|
||||||
|
type_stats[qt]['correct'] += 1
|
||||||
|
if q.topic_id:
|
||||||
|
tid = q.topic_id.name or str(q.topic_id.id)
|
||||||
|
topic_stats.setdefault(tid, {'correct': 0, 'total': 0})
|
||||||
|
topic_stats[tid]['total'] += 1
|
||||||
|
if ans.is_correct:
|
||||||
|
topic_stats[tid]['correct'] += 1
|
||||||
|
|
||||||
|
for qt, stats in type_stats.items():
|
||||||
|
pct = stats['correct'] / stats['total'] if stats['total'] else 0
|
||||||
|
if pct < 0.6:
|
||||||
|
question_type_weaknesses[qt] = round(pct, 2)
|
||||||
|
|
||||||
|
for topic, stats in topic_stats.items():
|
||||||
|
pct = stats['correct'] / stats['total'] if stats['total'] else 0
|
||||||
|
if pct < 0.6:
|
||||||
|
topic_weaknesses[topic] = round(pct, 2)
|
||||||
|
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
entity_id = user.entity_ids[0].id if user.entity_ids else False
|
||||||
|
|
||||||
|
profile = GapProfile.create({
|
||||||
|
'student_id': uid,
|
||||||
|
'source_type': 'placement',
|
||||||
|
'source_id': session.id,
|
||||||
|
'skill_gaps': json.dumps(skill_gaps),
|
||||||
|
'question_type_weaknesses': json.dumps(question_type_weaknesses),
|
||||||
|
'topic_weaknesses': json.dumps(topic_weaknesses),
|
||||||
|
'entity_id': entity_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'gap_profile_id': profile.id,
|
||||||
|
'student_id': profile.student_id.id,
|
||||||
|
'source_type': profile.source_type,
|
||||||
|
'skill_gaps': json.loads(profile.skill_gaps) if profile.skill_gaps else {},
|
||||||
|
'question_type_weaknesses': json.loads(profile.question_type_weaknesses) if profile.question_type_weaknesses else {},
|
||||||
|
'topic_weaknesses': json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {},
|
||||||
|
'created_at': profile.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('gap_analysis failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/course/auto-generate
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/auto-generate', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def auto_generate(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
uid = request.env.user.id
|
||||||
|
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||||
|
|
||||||
|
gap_profile_id = body.get('gap_profile_id')
|
||||||
|
if gap_profile_id:
|
||||||
|
profile = GapProfile.browse(int(gap_profile_id))
|
||||||
|
if not profile.exists():
|
||||||
|
return _error_response('Gap profile not found', 404)
|
||||||
|
else:
|
||||||
|
profile = GapProfile.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
], limit=1, order='created_at desc')
|
||||||
|
if not profile:
|
||||||
|
return _error_response('No gap profile found. Run gap-analysis first.', 404)
|
||||||
|
|
||||||
|
skill_gaps = json.loads(profile.skill_gaps) if profile.skill_gaps else {}
|
||||||
|
topic_weaknesses = json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {}
|
||||||
|
|
||||||
|
Ability = request.env['encoach.student.ability.model'].sudo()
|
||||||
|
abilities = Ability.search([('student_id', '=', uid)])
|
||||||
|
ability_map = {}
|
||||||
|
for ab in abilities:
|
||||||
|
ability_map[ab.skill] = ab.theta
|
||||||
|
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
entity_id = user.entity_ids[0].id if user.entity_ids else False
|
||||||
|
|
||||||
|
Course = request.env['op.course'].sudo()
|
||||||
|
course_vals = {
|
||||||
|
'name': f"Personalized Course for {user.name}",
|
||||||
|
'code': f"AUTO_{uid}_{profile.id}",
|
||||||
|
}
|
||||||
|
if hasattr(Course, 'generation_source'):
|
||||||
|
course_vals['generation_source'] = 'auto_gap'
|
||||||
|
if hasattr(Course, 'gap_profile_id'):
|
||||||
|
course_vals['gap_profile_id'] = profile.id
|
||||||
|
if hasattr(Course, 'entity_id'):
|
||||||
|
course_vals['entity_id'] = entity_id
|
||||||
|
|
||||||
|
course = Course.create(course_vals)
|
||||||
|
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
seq = 10
|
||||||
|
created_modules = []
|
||||||
|
|
||||||
|
sorted_gaps = sorted(skill_gaps.items(),
|
||||||
|
key=lambda x: x[1].get('theta', 0) if isinstance(x[1], dict) else 0)
|
||||||
|
|
||||||
|
for skill, gap_info in sorted_gaps:
|
||||||
|
theta = gap_info.get('theta', 0) if isinstance(gap_info, dict) else 0
|
||||||
|
if theta < -0.5:
|
||||||
|
cefr = 'a1'
|
||||||
|
elif theta < 0.0:
|
||||||
|
cefr = 'a2'
|
||||||
|
elif theta < 0.5:
|
||||||
|
cefr = 'b1'
|
||||||
|
else:
|
||||||
|
cefr = 'b2'
|
||||||
|
|
||||||
|
severity = gap_info.get('gap_severity', 'medium') if isinstance(gap_info, dict) else 'medium'
|
||||||
|
mod = Module.create({
|
||||||
|
'name': f"{skill.title()} Improvement ({severity})",
|
||||||
|
'course_id': course.id,
|
||||||
|
'cefr_target': cefr,
|
||||||
|
'auto_generated': True,
|
||||||
|
'generation_brief': json.dumps({
|
||||||
|
'skill': skill,
|
||||||
|
'theta': theta,
|
||||||
|
'severity': severity,
|
||||||
|
}),
|
||||||
|
'completion_criteria': 'score_threshold',
|
||||||
|
'score_threshold': PASS_THRESHOLD_DEFAULT,
|
||||||
|
'sequence': seq,
|
||||||
|
'status': 'available' if seq == 10 else 'locked',
|
||||||
|
})
|
||||||
|
created_modules.append(mod)
|
||||||
|
seq += 10
|
||||||
|
|
||||||
|
for topic_name, accuracy in sorted(topic_weaknesses.items(), key=lambda x: x[1]):
|
||||||
|
mod = Module.create({
|
||||||
|
'name': f"Topic Review: {topic_name}",
|
||||||
|
'course_id': course.id,
|
||||||
|
'cefr_target': 'b1',
|
||||||
|
'auto_generated': True,
|
||||||
|
'generation_brief': json.dumps({
|
||||||
|
'topic': topic_name,
|
||||||
|
'accuracy': accuracy,
|
||||||
|
}),
|
||||||
|
'completion_criteria': 'all_resources',
|
||||||
|
'sequence': seq,
|
||||||
|
'status': 'locked',
|
||||||
|
})
|
||||||
|
created_modules.append(mod)
|
||||||
|
seq += 10
|
||||||
|
|
||||||
|
if not created_modules:
|
||||||
|
Module.create({
|
||||||
|
'name': 'General Practice',
|
||||||
|
'course_id': course.id,
|
||||||
|
'cefr_target': 'b1',
|
||||||
|
'auto_generated': True,
|
||||||
|
'completion_criteria': 'all_resources',
|
||||||
|
'sequence': 10,
|
||||||
|
'status': 'available',
|
||||||
|
})
|
||||||
|
|
||||||
|
for i in range(1, len(created_modules)):
|
||||||
|
created_modules[i].write({
|
||||||
|
'prerequisite_module_id': created_modules[i - 1].id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response(_course_to_dict(course), 201)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('auto_generate failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/course/<int:course_id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/<int:course_id>', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
Course = request.env['op.course'].sudo()
|
||||||
|
course = Course.browse(course_id)
|
||||||
|
if not course.exists():
|
||||||
|
return _error_response('Course not found', 404)
|
||||||
|
|
||||||
|
data = _course_to_dict(course)
|
||||||
|
|
||||||
|
uid = request.env.user.id
|
||||||
|
Progress = request.env.get('encoach.course.progress')
|
||||||
|
if Progress is not None:
|
||||||
|
Progress = Progress.sudo()
|
||||||
|
for mod in data.get('modules', []):
|
||||||
|
progress_recs = Progress.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('module_id', '=', mod['id']),
|
||||||
|
])
|
||||||
|
mod['completed_resources'] = len(
|
||||||
|
progress_recs.filtered(lambda p: p.completed if hasattr(p, 'completed') else False)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _json_response(data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('course get failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PUT /api/course/<int:course_id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/<int:course_id>', type='http', auth='none',
|
||||||
|
methods=['PUT'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def update(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
Course = request.env['op.course'].sudo()
|
||||||
|
course = Course.browse(course_id)
|
||||||
|
if not course.exists():
|
||||||
|
return _error_response('Course not found', 404)
|
||||||
|
|
||||||
|
allowed_fields = {'name', 'description', 'code'}
|
||||||
|
vals = {}
|
||||||
|
for key in allowed_fields:
|
||||||
|
if key in body and hasattr(course, key):
|
||||||
|
vals[key] = body[key]
|
||||||
|
|
||||||
|
if vals:
|
||||||
|
course.write(vals)
|
||||||
|
|
||||||
|
module_order = body.get('module_order')
|
||||||
|
if module_order and isinstance(module_order, list):
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
for idx, mod_id in enumerate(module_order):
|
||||||
|
mod = Module.browse(int(mod_id))
|
||||||
|
if mod.exists() and mod.course_id.id == course.id:
|
||||||
|
mod.write({'sequence': (idx + 1) * 10})
|
||||||
|
|
||||||
|
return _json_response(_course_to_dict(course))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('course update failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/course/<int:course_id>/progress
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/<int:course_id>/progress', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def progress(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
module_id = body.get('module_id')
|
||||||
|
resource_id = body.get('resource_id')
|
||||||
|
completed = body.get('completed', False)
|
||||||
|
|
||||||
|
if not module_id:
|
||||||
|
return _error_response('module_id is required', 400)
|
||||||
|
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
mod = Module.browse(int(module_id))
|
||||||
|
if not mod.exists() or mod.course_id.id != course_id:
|
||||||
|
return _error_response('Module not found in this course', 404)
|
||||||
|
|
||||||
|
uid = request.env.user.id
|
||||||
|
|
||||||
|
if mod.status == 'locked':
|
||||||
|
return _error_response('Module is locked', 400)
|
||||||
|
|
||||||
|
if mod.status in ('locked', 'available'):
|
||||||
|
mod.write({'status': 'in_progress'})
|
||||||
|
|
||||||
|
Progress = request.env.get('encoach.course.progress')
|
||||||
|
if Progress is not None and resource_id:
|
||||||
|
Progress = Progress.sudo()
|
||||||
|
existing = Progress.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('module_id', '=', mod.id),
|
||||||
|
('resource_id', '=', int(resource_id)),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
existing.write({'completed': completed})
|
||||||
|
else:
|
||||||
|
Progress.create({
|
||||||
|
'student_id': uid,
|
||||||
|
'module_id': mod.id,
|
||||||
|
'resource_id': int(resource_id),
|
||||||
|
'completed': completed,
|
||||||
|
})
|
||||||
|
|
||||||
|
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
|
||||||
|
total_modules = len(all_modules)
|
||||||
|
completed_modules = len(all_modules.filtered(lambda m: m.status == 'completed'))
|
||||||
|
|
||||||
|
module_status = mod.status
|
||||||
|
if mod.completion_criteria == 'all_resources' and Progress is not None:
|
||||||
|
Progress = request.env['encoach.course.progress'].sudo()
|
||||||
|
mod_progress = Progress.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('module_id', '=', mod.id),
|
||||||
|
])
|
||||||
|
all_done = all(
|
||||||
|
getattr(p, 'completed', False) for p in mod_progress
|
||||||
|
) if mod_progress else False
|
||||||
|
if all_done and mod_progress:
|
||||||
|
mod.write({'status': 'completed'})
|
||||||
|
module_status = 'completed'
|
||||||
|
completed_modules += 1
|
||||||
|
self._unlock_next_module(mod, all_modules)
|
||||||
|
|
||||||
|
overall_pct = round((completed_modules / total_modules) * 100, 1) if total_modules else 0.0
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'module_id': mod.id,
|
||||||
|
'module_status': module_status,
|
||||||
|
'overall_progress_pct': overall_pct,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('progress failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
def _unlock_next_module(self, current_mod, all_modules):
|
||||||
|
"""Unlock the next sequential module when the current one is completed."""
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
next_mods = Module.search([
|
||||||
|
('course_id', '=', current_mod.course_id.id),
|
||||||
|
('prerequisite_module_id', '=', current_mod.id),
|
||||||
|
('status', '=', 'locked'),
|
||||||
|
])
|
||||||
|
for nm in next_mods:
|
||||||
|
nm.write({'status': 'available'})
|
||||||
|
|
||||||
|
if not next_mods:
|
||||||
|
found_current = False
|
||||||
|
for m in all_modules:
|
||||||
|
if found_current and m.status == 'locked':
|
||||||
|
m.write({'status': 'available'})
|
||||||
|
break
|
||||||
|
if m.id == current_mod.id:
|
||||||
|
found_current = True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/course/<int:course_id>/checkpoint
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/<int:course_id>/checkpoint', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def checkpoint(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
module_id = body.get('module_id')
|
||||||
|
score = body.get('score')
|
||||||
|
|
||||||
|
if not module_id or score is None:
|
||||||
|
return _error_response('module_id and score are required', 400)
|
||||||
|
|
||||||
|
score = float(score)
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
mod = Module.browse(int(module_id))
|
||||||
|
if not mod.exists() or mod.course_id.id != course_id:
|
||||||
|
return _error_response('Module not found in this course', 404)
|
||||||
|
|
||||||
|
threshold = mod.score_threshold or PASS_THRESHOLD_DEFAULT
|
||||||
|
passed = score >= threshold
|
||||||
|
|
||||||
|
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
|
||||||
|
next_module_id = None
|
||||||
|
action = 'continue'
|
||||||
|
|
||||||
|
if passed:
|
||||||
|
mod.write({'status': 'completed'})
|
||||||
|
self._unlock_next_module(mod, all_modules)
|
||||||
|
|
||||||
|
next_mods = Module.search([
|
||||||
|
('course_id', '=', course_id),
|
||||||
|
('status', 'in', ['available', 'locked']),
|
||||||
|
('sequence', '>', mod.sequence),
|
||||||
|
], limit=1, order='sequence asc')
|
||||||
|
if next_mods:
|
||||||
|
next_module_id = next_mods[0].id
|
||||||
|
action = 'next_module'
|
||||||
|
else:
|
||||||
|
action = 'course_complete'
|
||||||
|
elif score < STEP_DOWN_THRESHOLD:
|
||||||
|
action = 'remediation'
|
||||||
|
else:
|
||||||
|
action = 'retry'
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'module_id': mod.id,
|
||||||
|
'score': score,
|
||||||
|
'threshold': threshold,
|
||||||
|
'passed': passed,
|
||||||
|
'next_module_id': next_module_id,
|
||||||
|
'action': action,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('checkpoint failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/course/<int:course_id>/post-test
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/course/<int:course_id>/post-test', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def post_test(self, course_id, **kw):
|
||||||
|
try:
|
||||||
|
Course = request.env['op.course'].sudo()
|
||||||
|
course = Course.browse(course_id)
|
||||||
|
if not course.exists():
|
||||||
|
return _error_response('Course not found', 404)
|
||||||
|
|
||||||
|
Module = request.env['encoach.course.module'].sudo()
|
||||||
|
all_modules = Module.search([('course_id', '=', course_id)])
|
||||||
|
incomplete = all_modules.filtered(lambda m: m.status != 'completed')
|
||||||
|
if incomplete:
|
||||||
|
return _error_response(
|
||||||
|
f'{len(incomplete)} module(s) not completed. Finish all modules first.', 400
|
||||||
|
)
|
||||||
|
|
||||||
|
subject_id = course.subject_id.id if hasattr(course, 'subject_id') and course.subject_id else False
|
||||||
|
|
||||||
|
ExamCustom = request.env['encoach.exam.custom'].sudo()
|
||||||
|
exam = None
|
||||||
|
if subject_id:
|
||||||
|
exam = ExamCustom.search([
|
||||||
|
('subject_id', '=', subject_id),
|
||||||
|
('status', '=', 'published'),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
if not exam:
|
||||||
|
exam = ExamCustom.search([
|
||||||
|
('status', '=', 'published'),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
if not exam:
|
||||||
|
return _error_response('No published exam found for post-test', 404)
|
||||||
|
|
||||||
|
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||||
|
uid = request.env.user.id
|
||||||
|
|
||||||
|
existing = Assignment.search([
|
||||||
|
('exam_id', '=', exam.id),
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('status', '=', 'assigned'),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
return _json_response({'exam_assignment_id': existing.id})
|
||||||
|
|
||||||
|
assignment = Assignment.create({
|
||||||
|
'exam_id': exam.id,
|
||||||
|
'student_id': uid,
|
||||||
|
'status': 'assigned',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'exam_assignment_id': assignment.id}, 201)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('post_test failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from . import gap_profile
|
||||||
|
from . import course_extension
|
||||||
|
from . import course_section
|
||||||
|
from . import course_module
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class OpCourseExtension(models.Model):
|
||||||
|
_inherit = 'op.course'
|
||||||
|
|
||||||
|
generation_source = fields.Selection([
|
||||||
|
('manual', 'Manual'),
|
||||||
|
('auto_gap', 'Auto from Gap'),
|
||||||
|
('ai_english', 'AI English'),
|
||||||
|
('ai_ielts', 'AI IELTS'),
|
||||||
|
])
|
||||||
|
gap_profile_id = fields.Many2one('encoach.gap.profile', ondelete='set null')
|
||||||
|
progression_model = fields.Selection([
|
||||||
|
('linear', 'Linear'),
|
||||||
|
('parallel', 'Parallel'),
|
||||||
|
('adaptive', 'Adaptive'),
|
||||||
|
], default='linear')
|
||||||
|
target_band = fields.Float()
|
||||||
|
study_hours_week = fields.Integer()
|
||||||
|
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachCourseModule(models.Model):
|
||||||
|
_name = 'encoach.course.module'
|
||||||
|
_description = 'Course Module'
|
||||||
|
|
||||||
|
name = fields.Char(size=200, required=True)
|
||||||
|
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
|
||||||
|
cefr_target = fields.Selection([
|
||||||
|
('pre_a1', 'Pre-A1'),
|
||||||
|
('a1', 'A1'),
|
||||||
|
('a2', 'A2'),
|
||||||
|
('b1', 'B1'),
|
||||||
|
('b2', 'B2'),
|
||||||
|
('c1', 'C1'),
|
||||||
|
('c2', 'C2'),
|
||||||
|
])
|
||||||
|
auto_generated = fields.Boolean(default=False)
|
||||||
|
generation_brief = fields.Text()
|
||||||
|
completion_criteria = fields.Selection([
|
||||||
|
('all_resources', 'All Resources'),
|
||||||
|
('score_threshold', 'Score Threshold'),
|
||||||
|
('teacher_approval', 'Teacher Approval'),
|
||||||
|
], default='all_resources')
|
||||||
|
score_threshold = fields.Float()
|
||||||
|
prerequisite_module_id = fields.Many2one('encoach.course.module', ondelete='set null')
|
||||||
|
status = fields.Selection([
|
||||||
|
('locked', 'Locked'),
|
||||||
|
('available', 'Available'),
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('skipped', 'Skipped'),
|
||||||
|
], default='locked', required=True)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
section_id = fields.Many2one('encoach.course.section', ondelete='set null')
|
||||||
|
resource_ids = fields.Many2many('encoach.resource', string='Resources')
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachCourseSection(models.Model):
|
||||||
|
_name = 'encoach.course.section'
|
||||||
|
_description = 'Course Section'
|
||||||
|
_order = 'sequence'
|
||||||
|
|
||||||
|
name = fields.Char(size=200, required=True)
|
||||||
|
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
|
||||||
|
skill = fields.Selection([
|
||||||
|
('listening', 'Listening'),
|
||||||
|
('reading', 'Reading'),
|
||||||
|
('writing', 'Writing'),
|
||||||
|
('speaking', 'Speaking'),
|
||||||
|
('grammar', 'Grammar'),
|
||||||
|
('vocabulary', 'Vocabulary'),
|
||||||
|
('overall', 'Overall'),
|
||||||
|
])
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
module_ids = fields.One2many('encoach.course.module', 'section_id', string='Modules')
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachGapProfile(models.Model):
|
||||||
|
_name = 'encoach.gap.profile'
|
||||||
|
_description = 'Student Gap Profile'
|
||||||
|
|
||||||
|
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||||
|
source_type = fields.Selection([
|
||||||
|
('placement', 'Placement'),
|
||||||
|
('exam', 'Exam'),
|
||||||
|
], required=True)
|
||||||
|
source_id = fields.Integer()
|
||||||
|
skill_gaps = fields.Text()
|
||||||
|
question_type_weaknesses = fields.Text()
|
||||||
|
topic_weaknesses = fields.Text()
|
||||||
|
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||||
|
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_encoach_gap_profile_user,encoach.gap.profile.user,model_encoach_gap_profile,base.group_user,1,1,1,1
|
||||||
|
access_encoach_course_module_user,encoach.course.module.user,model_encoach_course_module,base.group_user,1,1,1,1
|
||||||
|
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
|
||||||
|
@@ -0,0 +1,40 @@
|
|||||||
|
.o_gap_analysis .ga-bar-track {
|
||||||
|
height: 28px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.o_gap_analysis .ga-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.o_gap_analysis .ga-bar-label {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.o_gap_analysis .ga-bar-row {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.o_gap_analysis .ga-student-card {
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.o_gap_analysis .ga-student-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
.o_gap_analysis .card {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.o_gap_analysis .cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { Layout } from "@web/search/layout";
|
||||||
|
|
||||||
|
class GapAnalysisVisualization extends Component {
|
||||||
|
static template = "encoach_course_gen.GapAnalysis";
|
||||||
|
static components = { Layout };
|
||||||
|
static props = ["*"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
studentId: null,
|
||||||
|
student: null,
|
||||||
|
gapProfile: null,
|
||||||
|
skillGaps: [],
|
||||||
|
weakTopics: [],
|
||||||
|
recommendations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
const ctx = this.props.action?.context || {};
|
||||||
|
this.state.studentId = ctx.student_id || ctx.active_id || null;
|
||||||
|
if (this.state.studentId) {
|
||||||
|
await this.loadGapData();
|
||||||
|
} else {
|
||||||
|
await this.loadStudentList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadStudentList() {
|
||||||
|
try {
|
||||||
|
this.state.studentList = await this.orm.searchRead(
|
||||||
|
"res.users",
|
||||||
|
[["account_source", "!=", false]],
|
||||||
|
["name", "email", "login"],
|
||||||
|
{ limit: 50, order: "name" },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load students:", e);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectStudent(studentId) {
|
||||||
|
this.state.studentId = studentId;
|
||||||
|
this.state.loading = true;
|
||||||
|
await this.loadGapData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadGapData() {
|
||||||
|
this.state.loading = true;
|
||||||
|
try {
|
||||||
|
const [profiles, students] = await Promise.all([
|
||||||
|
this.orm.searchRead(
|
||||||
|
"encoach.gap.profile",
|
||||||
|
[["student_id", "=", this.state.studentId]],
|
||||||
|
["student_id", "source_type", "skill_gaps", "question_type_weaknesses", "topic_weaknesses", "created_at"],
|
||||||
|
{ limit: 1, order: "created_at desc" },
|
||||||
|
),
|
||||||
|
this.orm.searchRead(
|
||||||
|
"res.users",
|
||||||
|
[["id", "=", this.state.studentId]],
|
||||||
|
["name", "email"],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.state.student = students.length ? students[0] : null;
|
||||||
|
this.state.gapProfile = profiles.length ? profiles[0] : null;
|
||||||
|
|
||||||
|
if (this.state.gapProfile) {
|
||||||
|
this._parseGapProfile(this.state.gapProfile);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Gap analysis load error:", e);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseGapProfile(profile) {
|
||||||
|
this.state.skillGaps = this._safeParseJson(profile.skill_gaps, []);
|
||||||
|
if (!Array.isArray(this.state.skillGaps)) {
|
||||||
|
const obj = this.state.skillGaps;
|
||||||
|
this.state.skillGaps = Object.entries(obj).map(([skill, data]) => ({
|
||||||
|
skill,
|
||||||
|
current: typeof data === "object" ? (data.current || 0) : data,
|
||||||
|
target: typeof data === "object" ? (data.target || 100) : 100,
|
||||||
|
gap: typeof data === "object" ? (data.gap || (data.target || 100) - (data.current || 0)) : (100 - data),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicRaw = this._safeParseJson(profile.topic_weaknesses, []);
|
||||||
|
if (Array.isArray(topicRaw)) {
|
||||||
|
this.state.weakTopics = topicRaw;
|
||||||
|
} else if (typeof topicRaw === "object") {
|
||||||
|
this.state.weakTopics = Object.entries(topicRaw).map(([name, score]) => ({
|
||||||
|
name,
|
||||||
|
score: typeof score === "number" ? score : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.recommendations = this._generateRecommendations();
|
||||||
|
}
|
||||||
|
|
||||||
|
_safeParseJson(val, fallback) {
|
||||||
|
if (!val) return fallback;
|
||||||
|
if (typeof val === "object") return val;
|
||||||
|
try { return JSON.parse(val); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_generateRecommendations() {
|
||||||
|
const recs = [];
|
||||||
|
for (const gap of this.state.skillGaps) {
|
||||||
|
const pct = gap.target > 0 ? (gap.current / gap.target) * 100 : 0;
|
||||||
|
if (pct < 50) {
|
||||||
|
recs.push({ icon: "fa-exclamation-triangle", color: "danger", text: `Critical gap in ${gap.skill} — intensive practice recommended` });
|
||||||
|
} else if (pct < 75) {
|
||||||
|
recs.push({ icon: "fa-arrow-up", color: "warning", text: `${gap.skill} needs improvement — targeted exercises suggested` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.state.weakTopics.length > 0) {
|
||||||
|
recs.push({ icon: "fa-book", color: "info", text: `Focus on ${this.state.weakTopics.length} weak topic(s) identified in the analysis` });
|
||||||
|
}
|
||||||
|
if (recs.length === 0) {
|
||||||
|
recs.push({ icon: "fa-thumbs-up", color: "success", text: "Student performance is on track across all areas" });
|
||||||
|
}
|
||||||
|
return recs;
|
||||||
|
}
|
||||||
|
|
||||||
|
barWidth(current, target) {
|
||||||
|
if (!target || target <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((current / target) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
barColor(current, target) {
|
||||||
|
const pct = target > 0 ? (current / target) * 100 : 0;
|
||||||
|
if (pct >= 75) return "bg-success";
|
||||||
|
if (pct >= 50) return "bg-warning";
|
||||||
|
return "bg-danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
this.state.studentId = null;
|
||||||
|
this.state.student = null;
|
||||||
|
this.state.gapProfile = null;
|
||||||
|
this.state.skillGaps = [];
|
||||||
|
this.state.weakTopics = [];
|
||||||
|
this.state.recommendations = [];
|
||||||
|
this.state.loading = true;
|
||||||
|
this.loadStudentList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("encoach_gap_analysis", GapAnalysisVisualization);
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="encoach_course_gen.GapAnalysis">
|
||||||
|
<Layout display="{ controlPanel: {} }">
|
||||||
|
<div class="o_gap_analysis">
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
|
||||||
|
<t t-if="state.loading">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||||
|
<p class="mt-2 text-muted">Analyzing gaps...</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Student Selector -->
|
||||||
|
<t t-elif="!state.studentId and state.studentList">
|
||||||
|
<h2 class="mb-3">Gap Analysis</h2>
|
||||||
|
<p class="text-muted mb-4">Select a student to view their skill gap analysis.</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
|
||||||
|
<div class="col-lg-3 col-md-4 col-sm-6">
|
||||||
|
<div class="card shadow-sm h-100 cursor-pointer ga-student-card"
|
||||||
|
t-on-click="() => this.selectStudent(st.id)">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="rounded-circle bg-primary bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
|
||||||
|
<i class="fa fa-user text-primary"/>
|
||||||
|
</div>
|
||||||
|
<h6 class="card-title mb-0" t-esc="st.name"/>
|
||||||
|
<small class="text-muted" t-esc="st.email"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Gap Analysis View -->
|
||||||
|
<t t-elif="state.student">
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
|
||||||
|
t-if="!this.props.action?.context?.student_id">
|
||||||
|
<i class="fa fa-arrow-left me-1"/> Back
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0">Gap Analysis</h2>
|
||||||
|
<small class="text-muted">
|
||||||
|
Student: <strong t-esc="state.student.name"/>
|
||||||
|
<t t-if="state.gapProfile">
|
||||||
|
— Source: <span class="badge bg-secondary" t-esc="state.gapProfile.source_type"/>
|
||||||
|
</t>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="!state.gapProfile">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fa fa-info-circle me-2"/>
|
||||||
|
No gap profile found for this student. A placement test or exam is required first.
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-else="">
|
||||||
|
<!-- Skill Gap Bars -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fa fa-bar-chart me-2 text-primary"/>
|
||||||
|
Skill Gaps
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<t t-if="state.skillGaps.length">
|
||||||
|
<t t-foreach="state.skillGaps" t-as="gap" t-key="gap.skill">
|
||||||
|
<div class="ga-bar-row mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="fw-bold text-capitalize" t-esc="gap.skill"/>
|
||||||
|
<span class="small text-muted">
|
||||||
|
<t t-esc="gap.current"/> / <t t-esc="gap.target"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ga-bar-track">
|
||||||
|
<div class="ga-bar-fill"
|
||||||
|
t-att-class="barColor(gap.current, gap.target)"
|
||||||
|
t-att-style="'width:' + barWidth(gap.current, gap.target) + '%'">
|
||||||
|
<span class="ga-bar-label" t-if="barWidth(gap.current, gap.target) > 15">
|
||||||
|
<t t-esc="barWidth(gap.current, gap.target)"/>%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<p class="text-muted mb-0">No skill gap data available.</p>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Weak Topics -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fa fa-warning me-2 text-warning"/>
|
||||||
|
Weak Topics
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<t t-if="state.weakTopics.length">
|
||||||
|
<t t-foreach="state.weakTopics" t-as="topic" t-key="topic_index">
|
||||||
|
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
||||||
|
<span>
|
||||||
|
<i class="fa fa-circle text-danger me-2" style="font-size:0.5rem; vertical-align:middle"/>
|
||||||
|
<t t-esc="topic.name || topic"/>
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-danger bg-opacity-75" t-if="topic.score !== undefined">
|
||||||
|
<t t-esc="topic.score"/>%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<p class="text-muted text-center mb-0 py-3">
|
||||||
|
<i class="fa fa-check-circle text-success me-1"/>
|
||||||
|
No weak topics identified
|
||||||
|
</p>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recommendations -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fa fa-lightbulb-o me-2 text-info"/>
|
||||||
|
Recommended Actions
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<t t-foreach="state.recommendations" t-as="rec" t-key="rec_index">
|
||||||
|
<div class="d-flex align-items-start py-2 border-bottom">
|
||||||
|
<div class="rounded-circle d-flex align-items-center justify-content-center me-3 flex-shrink-0"
|
||||||
|
t-att-class="'bg-' + rec.color + ' bg-opacity-10'"
|
||||||
|
style="width:36px; height:36px">
|
||||||
|
<i class="fa" t-att-class="rec.icon + ' text-' + rec.color"/>
|
||||||
|
</div>
|
||||||
|
<span class="small" t-esc="rec.text"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="!state.studentId">
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="fa fa-line-chart fa-3x mb-3 d-block"/>
|
||||||
|
<p>No student selected. Open from a student record or select one above.</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="action_all_courses" model="ir.actions.act_window">
|
||||||
|
<field name="name">All Courses</field>
|
||||||
|
<field name="res_model">op.course</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_all_courses"
|
||||||
|
name="All Courses"
|
||||||
|
parent="encoach_core.menu_encoach_courses"
|
||||||
|
action="encoach_course_gen.action_all_courses"
|
||||||
|
sequence="5"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_course_modules"
|
||||||
|
name="Course Modules"
|
||||||
|
parent="encoach_core.menu_encoach_courses"
|
||||||
|
action="encoach_course_gen.action_course_module"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_gap_analysis"
|
||||||
|
name="Gap Analysis"
|
||||||
|
parent="encoach_core.menu_encoach_courses"
|
||||||
|
action="encoach_course_gen.action_gap_profile"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_course_module_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.module.form</field>
|
||||||
|
<field name="model">encoach.course.module</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Course Module">
|
||||||
|
<header>
|
||||||
|
<field name="status" widget="statusbar" statusbar_visible="locked,available,in_progress,completed"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="cefr_target"/>
|
||||||
|
<field name="auto_generated"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="completion_criteria"/>
|
||||||
|
<field name="score_threshold" invisible="completion_criteria != 'score_threshold'"/>
|
||||||
|
<field name="prerequisite_module_id"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="generation_brief"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_course_module_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.module.list</field>
|
||||||
|
<field name="model">encoach.course.module</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Course Modules">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="cefr_target"/>
|
||||||
|
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_course_module_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.module.search</field>
|
||||||
|
<field name="model">encoach.course.module</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Course Modules">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Locked" name="locked" domain="[('status', '=', 'locked')]"/>
|
||||||
|
<filter string="Available" name="available" domain="[('status', '=', 'available')]"/>
|
||||||
|
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||||
|
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
|
||||||
|
<filter string="Skipped" name="skipped" domain="[('status', '=', 'skipped')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_course_module" model="ir.actions.act_window">
|
||||||
|
<field name="name">Course Modules</field>
|
||||||
|
<field name="res_model">encoach.course.module</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_encoach_course_section_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.section.form</field>
|
||||||
|
<field name="model">encoach.course.section</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Course Section">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="skill"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Modules">
|
||||||
|
<field name="module_ids">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="cefr_target"/>
|
||||||
|
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_course_section_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.section.list</field>
|
||||||
|
<field name="model">encoach.course.section</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Course Sections">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="skill" widget="badge"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_course_section_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.section.search</field>
|
||||||
|
<field name="model">encoach.course.section</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Sections">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||||
|
<filter string="Skill" name="group_skill" context="{'group_by': 'skill'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_encoach_course_section" model="ir.actions.act_window">
|
||||||
|
<field name="name">Course Sections</field>
|
||||||
|
<field name="res_model">encoach.course.section</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="action_gap_analysis_viz" model="ir.actions.client">
|
||||||
|
<field name="name">Gap Analysis</field>
|
||||||
|
<field name="tag">encoach_gap_analysis</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_gap_analysis_viz"
|
||||||
|
name="Gap Analysis (Visual)"
|
||||||
|
parent="encoach_core.menu_encoach_courses"
|
||||||
|
action="action_gap_analysis_viz"
|
||||||
|
sequence="25"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_gap_profile_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.gap.profile.form</field>
|
||||||
|
<field name="model">encoach.gap.profile</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Gap Profile">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="source_type"/>
|
||||||
|
<field name="source_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="entity_id"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Skill Gaps" name="skill_gaps">
|
||||||
|
<field name="skill_gaps"/>
|
||||||
|
</page>
|
||||||
|
<page string="Question Type Weaknesses" name="question_type_weaknesses">
|
||||||
|
<field name="question_type_weaknesses"/>
|
||||||
|
</page>
|
||||||
|
<page string="Topic Weaknesses" name="topic_weaknesses">
|
||||||
|
<field name="topic_weaknesses"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_gap_profile_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.gap.profile.list</field>
|
||||||
|
<field name="model">encoach.gap.profile</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Gap Profiles">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="source_type"/>
|
||||||
|
<field name="entity_id"/>
|
||||||
|
<field name="created_at"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_gap_profile_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.gap.profile.search</field>
|
||||||
|
<field name="model">encoach.gap.profile</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Gap Profiles">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Placement" name="placement" domain="[('source_type', '=', 'placement')]"/>
|
||||||
|
<filter string="Exam" name="exam" domain="[('source_type', '=', 'exam')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||||
|
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_gap_profile" model="ir.actions.act_window">
|
||||||
|
<field name="name">Gap Analysis</field>
|
||||||
|
<field name="res_model">encoach.gap.profile</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from . import services
|
||||||
|
from . import controllers
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
'name': 'EnCoach Entity Onboarding',
|
||||||
|
'version': '19.0.1.0',
|
||||||
|
'category': 'Education',
|
||||||
|
'summary': 'Entity student CSV bulk upload, account creation, credential management',
|
||||||
|
'author': 'EnCoach',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'depends': ['encoach_core'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import entity_onboard
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
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__)
|
||||||
|
|
||||||
|
EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachEntityOnboardController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/entity/students/validate-csv
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/students/validate-csv', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def validate_csv(self, **kw):
|
||||||
|
try:
|
||||||
|
uploaded = request.httprequest.files.get('file')
|
||||||
|
if not uploaded:
|
||||||
|
return _error_response('CSV file is required (multipart field "file")', 400)
|
||||||
|
|
||||||
|
raw = uploaded.read()
|
||||||
|
from odoo.addons.encoach_entity_onboard.services.csv_parser import CsvParser
|
||||||
|
parser = CsvParser()
|
||||||
|
result = parser.validate(raw)
|
||||||
|
|
||||||
|
valid_rows = result['valid_rows']
|
||||||
|
parse_errors = result['errors']
|
||||||
|
|
||||||
|
row_errors = []
|
||||||
|
for err in parse_errors:
|
||||||
|
row_errors.append({'row': 0, 'field': '', 'message': err})
|
||||||
|
|
||||||
|
User = request.env['res.users'].sudo()
|
||||||
|
dedup_errors = []
|
||||||
|
for idx, row in enumerate(valid_rows):
|
||||||
|
email = row.get('email', '')
|
||||||
|
if email and not EMAIL_RE.match(email):
|
||||||
|
row_errors.append({
|
||||||
|
'row': idx + 2,
|
||||||
|
'field': 'email',
|
||||||
|
'message': f"Invalid email format: {email}",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if email:
|
||||||
|
existing = User.search([('login', '=', email)], limit=1)
|
||||||
|
if existing:
|
||||||
|
dedup_errors.append({
|
||||||
|
'row': idx + 2,
|
||||||
|
'field': 'email',
|
||||||
|
'message': f"Duplicate email already exists in DB: {email}",
|
||||||
|
})
|
||||||
|
|
||||||
|
all_errors = row_errors + dedup_errors
|
||||||
|
error_count = len(all_errors)
|
||||||
|
truly_valid = [
|
||||||
|
r for i, r in enumerate(valid_rows)
|
||||||
|
if not any(e['row'] == i + 2 for e in all_errors)
|
||||||
|
]
|
||||||
|
|
||||||
|
preview = truly_valid[:5]
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'valid_count': len(truly_valid),
|
||||||
|
'error_count': error_count,
|
||||||
|
'errors': all_errors,
|
||||||
|
'preview': preview,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('validate_csv failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/entity/students/bulk-create
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/students/bulk-create', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def bulk_create(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
rows = body.get('rows', [])
|
||||||
|
entity_id = body.get('entity_id')
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return _error_response('rows array is required', 400)
|
||||||
|
if not entity_id:
|
||||||
|
return _error_response('entity_id is required', 400)
|
||||||
|
|
||||||
|
entity_id = int(entity_id)
|
||||||
|
Entity = request.env['encoach.entity'].sudo()
|
||||||
|
entity = Entity.browse(entity_id)
|
||||||
|
if not entity.exists():
|
||||||
|
return _error_response('Entity not found', 404)
|
||||||
|
|
||||||
|
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||||
|
service = CredentialService()
|
||||||
|
created_users = service.create_accounts(request.env, rows, entity_id)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'created_count': len(created_users),
|
||||||
|
'user_ids': created_users.ids,
|
||||||
|
}, 201)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('bulk_create failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/entity/students/send-credentials
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/students/send-credentials', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def send_credentials(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
user_ids = body.get('user_ids', [])
|
||||||
|
if not user_ids:
|
||||||
|
return _error_response('user_ids array is required', 400)
|
||||||
|
|
||||||
|
user_ids = [int(uid) for uid in user_ids]
|
||||||
|
|
||||||
|
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||||
|
service = CredentialService()
|
||||||
|
service.send_credentials(request.env, user_ids)
|
||||||
|
|
||||||
|
return _json_response({'sent_count': len(user_ids)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('send_credentials failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/entity/students/<int:student_id>/resend-credentials
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/students/<int:student_id>/resend-credentials',
|
||||||
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def resend_credentials(self, student_id, **kw):
|
||||||
|
try:
|
||||||
|
User = request.env['res.users'].sudo()
|
||||||
|
user = User.browse(student_id)
|
||||||
|
if not user.exists():
|
||||||
|
return _error_response('Student not found', 404)
|
||||||
|
|
||||||
|
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||||
|
service = CredentialService()
|
||||||
|
service.resend_credentials(request.env, student_id)
|
||||||
|
|
||||||
|
return _json_response({'sent': True})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('resend_credentials failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/entity/students/resend-all-pending
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/students/resend-all-pending', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def resend_all_pending(self, **kw):
|
||||||
|
try:
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
domain = [
|
||||||
|
('first_login', '=', True),
|
||||||
|
('account_source', '=', 'entity_bulk_upload'),
|
||||||
|
]
|
||||||
|
|
||||||
|
if user.entity_ids:
|
||||||
|
domain.append(('entity_ids', 'in', user.entity_ids.ids))
|
||||||
|
|
||||||
|
User = request.env['res.users'].sudo()
|
||||||
|
pending_users = User.search(domain)
|
||||||
|
|
||||||
|
if not pending_users:
|
||||||
|
return _json_response({'sent_count': 0})
|
||||||
|
|
||||||
|
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||||
|
service = CredentialService()
|
||||||
|
service.send_credentials(request.env, pending_users.ids)
|
||||||
|
|
||||||
|
return _json_response({'sent_count': len(pending_users)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('resend_all_pending failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/entity/students/sis-import
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/entity/students/sis-import', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def sis_import(self, **kw):
|
||||||
|
try:
|
||||||
|
sis_data = None
|
||||||
|
uploaded = request.httprequest.files.get('file')
|
||||||
|
if uploaded:
|
||||||
|
raw = uploaded.read()
|
||||||
|
try:
|
||||||
|
sis_data = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return _error_response('Invalid JSON file', 400)
|
||||||
|
else:
|
||||||
|
body = _get_json_body()
|
||||||
|
sis_data = body.get('sis_data', [])
|
||||||
|
|
||||||
|
if not sis_data or not isinstance(sis_data, list):
|
||||||
|
return _error_response('sis_data must be a non-empty JSON array', 400)
|
||||||
|
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
entity_id = None
|
||||||
|
if user.entity_ids:
|
||||||
|
entity_id = user.entity_ids[0].id
|
||||||
|
|
||||||
|
User = request.env['res.users'].sudo()
|
||||||
|
imported_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for idx, record in enumerate(sis_data):
|
||||||
|
email = (record.get('email') or '').strip()
|
||||||
|
name = (record.get('name') or '').strip()
|
||||||
|
national_id = (record.get('national_id') or '').strip()
|
||||||
|
phone = (record.get('phone') or '').strip()
|
||||||
|
|
||||||
|
if not email or not name:
|
||||||
|
errors.append({
|
||||||
|
'index': idx,
|
||||||
|
'message': 'name and email are required',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not EMAIL_RE.match(email):
|
||||||
|
errors.append({
|
||||||
|
'index': idx,
|
||||||
|
'message': f'Invalid email: {email}',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = User.search([('login', '=', email)], limit=1)
|
||||||
|
if existing:
|
||||||
|
update_vals = {'name': name}
|
||||||
|
if phone:
|
||||||
|
update_vals['phone'] = phone
|
||||||
|
existing.write(update_vals)
|
||||||
|
if entity_id and hasattr(existing, 'entity_ids'):
|
||||||
|
if entity_id not in existing.entity_ids.ids:
|
||||||
|
existing.write({'entity_ids': [(4, entity_id)]})
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
create_vals = {
|
||||||
|
'name': name,
|
||||||
|
'login': email,
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
'password': national_id or email.split('@')[0],
|
||||||
|
'account_source': 'entity_bulk_upload',
|
||||||
|
'account_status': 'activated',
|
||||||
|
}
|
||||||
|
if entity_id:
|
||||||
|
create_vals['entity_id'] = entity_id
|
||||||
|
User.create(create_vals)
|
||||||
|
imported_count += 1
|
||||||
|
except Exception as rec_err:
|
||||||
|
errors.append({
|
||||||
|
'index': idx,
|
||||||
|
'message': str(rec_err),
|
||||||
|
})
|
||||||
|
_logger.warning('SIS import error for row %d: %s', idx, rec_err)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'imported_count': imported_count,
|
||||||
|
'updated_count': updated_count,
|
||||||
|
'errors': errors,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('sis_import failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
from . import csv_parser
|
||||||
|
from . import credential_service
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user