feat: EnCoach V2 — complete OWL refactor with 15 new modules

Full architectural refactor from React to Odoo OWL:

- 15 new Odoo modules: signup, placement, exam_template, scoring,
  course_gen, entity_onboard, ai_course, quality_gate,
  ielts_validation, pdf_report, verification, math, it, portal,
  exam_session
- 6 modified modules: core (new user fields), exam (template types),
  adaptive (events/paths/settings), branding (white-label),
  resources (CEFR/AI fields), taxonomy (Math+IT hierarchies)
- ~79 new REST API endpoints across all controller packages
- ~50 admin backend views (form/list/kanban/search/menu)
- 5 custom OWL components: dashboard, content pool, exam validation,
  gap analysis, adaptive timeline
- POS-inspired full-screen exam session with 9 question renderers
- Student portal with 12 website pages (QWeb + OWL)
- AI/ML services: IRT 3PL CAT engine, CEFR mapper, quality gates,
  IELTS validator, SymPy math scorer, speaking evaluator, adaptive engine
- Seed data: IELTS/TOEFL/STEP/IC3 templates, 30+ sample questions,
  English/Math/IT taxonomy trees with 50+ topics
- Updated requirements.txt with new dependencies

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-07 01:53:06 +04:00
parent a4b9ab62cf
commit 6c93c5d600
233 changed files with 23446 additions and 0 deletions

9
.gitignore vendored
View File

@@ -26,3 +26,12 @@ Thumbs.db
# Docker
*.tar
# Frontend repo (separate repository)
new_project/encoach_frontend_new_v1/
# Local dev artifacts
miniconda3/
pgdata/
.conda-envs/
.conda-pkgs/

View 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 ~1015 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` |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import services

View File

@@ -0,0 +1,25 @@
{
'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'],
'data': [
'security/ir.model.access.csv',
'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',
}

View File

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

View File

@@ -0,0 +1,244 @@
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__)
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/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)

View File

@@ -0,0 +1,8 @@
from . import proficiency
from . import learning_plan
from . import learning_plan_item
from . import diagnostic_session
from . import content_cache
from . import adaptive_event
from . import adaptive_path
from . import adaptive_settings

View File

@@ -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)

View File

@@ -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()

View File

@@ -0,0 +1,15 @@
from odoo import 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)

View File

@@ -0,0 +1,18 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_proficiency_student,encoach.proficiency.student,model_encoach_proficiency,encoach_core.group_encoach_student,1,0,0,0
access_proficiency_teacher,encoach.proficiency.teacher,model_encoach_proficiency,encoach_core.group_encoach_teacher,1,1,1,0
access_proficiency_admin,encoach.proficiency.admin,model_encoach_proficiency,encoach_core.group_encoach_admin,1,1,1,1
access_learning_plan_student,encoach.learning.plan.student,model_encoach_learning_plan,encoach_core.group_encoach_student,1,1,0,0
access_learning_plan_teacher,encoach.learning.plan.teacher,model_encoach_learning_plan,encoach_core.group_encoach_teacher,1,1,1,0
access_learning_plan_admin,encoach.learning.plan.admin,model_encoach_learning_plan,encoach_core.group_encoach_admin,1,1,1,1
access_plan_item_student,encoach.learning.plan.item.student,model_encoach_learning_plan_item,encoach_core.group_encoach_student,1,1,0,0
access_plan_item_teacher,encoach.learning.plan.item.teacher,model_encoach_learning_plan_item,encoach_core.group_encoach_teacher,1,1,1,0
access_plan_item_admin,encoach.learning.plan.item.admin,model_encoach_learning_plan_item,encoach_core.group_encoach_admin,1,1,1,1
access_diagnostic_student,encoach.diagnostic.session.student,model_encoach_diagnostic_session,encoach_core.group_encoach_student,1,1,1,0
access_diagnostic_teacher,encoach.diagnostic.session.teacher,model_encoach_diagnostic_session,encoach_core.group_encoach_teacher,1,1,1,0
access_diagnostic_admin,encoach.diagnostic.session.admin,model_encoach_diagnostic_session,encoach_core.group_encoach_admin,1,1,1,1
access_content_cache_student,encoach.content.cache.student,model_encoach_content_cache,encoach_core.group_encoach_student,1,0,0,0
access_content_cache_admin,encoach.content.cache.admin,model_encoach_content_cache,encoach_core.group_encoach_admin,1,1,1,1
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_proficiency_student encoach.proficiency.student model_encoach_proficiency encoach_core.group_encoach_student 1 0 0 0
3 access_proficiency_teacher encoach.proficiency.teacher model_encoach_proficiency encoach_core.group_encoach_teacher 1 1 1 0
4 access_proficiency_admin encoach.proficiency.admin model_encoach_proficiency encoach_core.group_encoach_admin 1 1 1 1
5 access_learning_plan_student encoach.learning.plan.student model_encoach_learning_plan encoach_core.group_encoach_student 1 1 0 0
6 access_learning_plan_teacher encoach.learning.plan.teacher model_encoach_learning_plan encoach_core.group_encoach_teacher 1 1 1 0
7 access_learning_plan_admin encoach.learning.plan.admin model_encoach_learning_plan encoach_core.group_encoach_admin 1 1 1 1
8 access_plan_item_student encoach.learning.plan.item.student model_encoach_learning_plan_item encoach_core.group_encoach_student 1 1 0 0
9 access_plan_item_teacher encoach.learning.plan.item.teacher model_encoach_learning_plan_item encoach_core.group_encoach_teacher 1 1 1 0
10 access_plan_item_admin encoach.learning.plan.item.admin model_encoach_learning_plan_item encoach_core.group_encoach_admin 1 1 1 1
11 access_diagnostic_student encoach.diagnostic.session.student model_encoach_diagnostic_session encoach_core.group_encoach_student 1 1 1 0
12 access_diagnostic_teacher encoach.diagnostic.session.teacher model_encoach_diagnostic_session encoach_core.group_encoach_teacher 1 1 1 0
13 access_diagnostic_admin encoach.diagnostic.session.admin model_encoach_diagnostic_session encoach_core.group_encoach_admin 1 1 1 1
14 access_content_cache_student encoach.content.cache.student model_encoach_content_cache encoach_core.group_encoach_student 1 0 0 0
15 access_content_cache_admin encoach.content.cache.admin model_encoach_content_cache encoach_core.group_encoach_admin 1 1 1 1
16 access_adaptive_event_user encoach.adaptive.event.user model_encoach_adaptive_event base.group_user 1 1 1 1
17 access_adaptive_path_user encoach.adaptive.path.user model_encoach_adaptive_path base.group_user 1 1 1 1
18 access_adaptive_settings_user encoach.adaptive.settings.user model_encoach_adaptive_settings base.group_user 1 1 1 1

View File

@@ -0,0 +1 @@
from .adaptive_engine import AdaptiveEngine

View File

@@ -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

View File

@@ -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;
}

View File

@@ -0,0 +1,153 @@
/** @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.rpc = useService("rpc");
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.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["account_source", "!=", false]], ["name", "email"]],
kwargs: { 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.rpc("/web/dataset/call_kw", {
model: "encoach.adaptive.event",
method: "search_read",
args: [domain, ["student_id", "course_id", "event_type", "signal_name", "signal_value", "decision", "context", "created_at"]],
kwargs: { limit: 100, order: "created_at desc" },
}),
this.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["id", "=", this.state.studentId]], ["name", "email"]],
kwargs: {},
}),
]);
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);

View File

@@ -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>

View File

@@ -0,0 +1,69 @@
<?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/>
<group expand="0" string="Group By">
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
</group>
</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
from . import models
from . import services
from . import controllers

View File

@@ -0,0 +1,18 @@
{
'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',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,264 @@
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__)
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)

View File

@@ -0,0 +1,2 @@
from . import ai_generation_log
from . import ai_ielts_generation_log

View File

@@ -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.Many2one('encoach.resource', ondelete='set null')
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()

View File

@@ -0,0 +1,26 @@
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)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 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

View File

@@ -0,0 +1,2 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline

View File

@@ -0,0 +1,109 @@
import json
import logging
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
_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)}
env['encoach.ai.generation.log'].create({
'course_type': 'general_english',
'brief': json.dumps(gap_profile),
'status': 'quality_check',
'attempts': 1,
})
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}

View File

@@ -0,0 +1,155 @@
import json
import logging
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
_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)}
env['encoach.ai.ielts.generation.log'].create({
'skill': skill,
'brief': brief_text,
'status': 'format_check',
'attempts': 1,
})
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', []),
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View 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',
}

View File

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

View File

@@ -0,0 +1,225 @@
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 '',
})
auth_header = request.httprequest.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return _error_response('Missing or invalid Authorization header', 401)
token = auth_header[7:]
uid = validate_token(token)
if not uid:
return _error_response('Invalid or expired token', 401)
request.update_env(user=uid)
user = request.env.user.sudo()
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)

View File

@@ -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 '',
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,26 @@
{
'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/entity_level_mapping_views.xml',
'views/encoach_menus.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',
}

View File

@@ -0,0 +1,6 @@
from . import encoach_user
from . import encoach_entity
from . import encoach_role
from . import encoach_permission
from . import encoach_invite_code
from . import entity_level_mapping

View File

@@ -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,
}

View File

@@ -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',
}

View File

@@ -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'),
])

View File

@@ -0,0 +1,16 @@
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_code_student,encoach.invite.code.student,model_encoach_invite_code,group_encoach_student,1,0,0,0
access_encoach_invite_code_teacher,encoach.invite.code.teacher,model_encoach_invite_code,group_encoach_teacher,1,1,0,0
access_encoach_invite_code_admin,encoach.invite.code.admin,model_encoach_invite_code,group_encoach_admin,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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_entity_student encoach.entity.student model_encoach_entity group_encoach_student 1 0 0 0
3 access_encoach_entity_teacher encoach.entity.teacher model_encoach_entity group_encoach_teacher 1 1 0 0
4 access_encoach_entity_admin encoach.entity.admin model_encoach_entity group_encoach_admin 1 1 1 1
5 access_encoach_role_student encoach.role.student model_encoach_role group_encoach_student 1 0 0 0
6 access_encoach_role_teacher encoach.role.teacher model_encoach_role group_encoach_teacher 1 1 0 0
7 access_encoach_role_admin encoach.role.admin model_encoach_role group_encoach_admin 1 1 1 1
8 access_encoach_permission_student encoach.permission.student model_encoach_permission group_encoach_student 1 0 0 0
9 access_encoach_permission_teacher encoach.permission.teacher model_encoach_permission group_encoach_teacher 1 0 0 0
10 access_encoach_permission_admin encoach.permission.admin model_encoach_permission group_encoach_admin 1 1 1 1
11 access_encoach_invite_code_student encoach.invite.code.student model_encoach_invite_code group_encoach_student 1 0 0 0
12 access_encoach_invite_code_teacher encoach.invite.code.teacher model_encoach_invite_code group_encoach_teacher 1 1 0 0
13 access_encoach_invite_code_admin encoach.invite.code.admin model_encoach_invite_code group_encoach_admin 1 1 1 1
14 access_encoach_level_mapping_student encoach.entity.level.mapping.student model_encoach_entity_level_mapping group_encoach_student 1 0 0 0
15 access_encoach_level_mapping_teacher encoach.entity.level.mapping.teacher model_encoach_entity_level_mapping group_encoach_teacher 1 1 0 0
16 access_encoach_level_mapping_admin encoach.entity.level.mapping.admin model_encoach_entity_level_mapping group_encoach_admin 1 1 1 1

View File

@@ -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;
}

View File

@@ -0,0 +1,97 @@
/** @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.rpc = useService("rpc");
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,
recent_signups: 0,
avg_score: 0,
},
recentAttempts: [],
courseProgress: [],
});
onWillStart(async () => {
await this.loadDashboardData();
});
}
async loadDashboardData() {
try {
const [students, teachers, courses, exams, sessions, pendingGrading, pendingApproval, entities] = await Promise.all([
this.rpc("/web/dataset/call_kw", { model: "res.users", method: "search_count", args: [[["account_source", "!=", false]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "res.users", method: "search_count", args: [[["user_type", "=", "teacher"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "op.course", method: "search_count", args: [[]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.exam.custom", method: "search_count", args: [[]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.student.attempt", method: "search_count", args: [[["status", "=", "in_progress"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.student.attempt", method: "search_count", args: [[["status", "=", "scoring"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.student.attempt", method: "search_count", args: [[["status", "=", "pending_approval"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.entity", method: "search_count", args: [[]], kwargs: {} }),
]);
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.rpc("/web/dataset/call_kw", {
model: "encoach.student.attempt",
method: "search_read",
args: [[], ["student_id", "exam_id", "status", "overall_band", "started_at"]],
kwargs: { 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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,26 @@
{
'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'],
'data': [
'security/ir.model.access.csv',
'views/gap_profile_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,
}

View File

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

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
from . import gap_profile
from . import course_extension
from . import course_module

View File

@@ -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')

View File

@@ -0,0 +1,35 @@
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)

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_gap_profile_user encoach.gap.profile.user model_encoach_gap_profile base.group_user 1 1 1 1
3 access_encoach_course_module_user encoach.course.module.user model_encoach_course_module base.group_user 1 1 1 1

View File

@@ -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;
}

View File

@@ -0,0 +1,163 @@
/** @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.rpc = useService("rpc");
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.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["account_source", "!=", false]], ["name", "email", "login"]],
kwargs: { 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.rpc("/web/dataset/call_kw", {
model: "encoach.gap.profile",
method: "search_read",
args: [[["student_id", "=", this.state.studentId]], ["student_id", "source_type", "skill_gaps", "question_type_weaknesses", "topic_weaknesses", "created_at"]],
kwargs: { limit: 1, order: "created_at desc" },
}),
this.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["id", "=", this.state.studentId]], ["name", "email"]],
kwargs: {},
}),
]);
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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,76 @@
<?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/>
<group expand="0" string="Group By">
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
</group>
</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>

View File

@@ -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>

View File

@@ -0,0 +1,74 @@
<?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/>
<group expand="0" string="Group By">
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
</group>
</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>

View File

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

View File

@@ -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,
}

View File

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

View File

@@ -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)

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

View File

@@ -0,0 +1,2 @@
from . import csv_parser
from . import credential_service

View File

@@ -0,0 +1,72 @@
import logging
_logger = logging.getLogger(__name__)
class CredentialService:
def create_accounts(self, env, valid_rows, entity_id):
"""Create res.users records from validated CSV rows.
Each user gets password=national_id, account_source='entity_bulk_upload',
and account_status='activated'.
Args:
env: Odoo environment.
valid_rows: list of dicts with keys name, email, national_id, phone.
entity_id: int, the entity that owns these students.
Returns:
recordset of created res.users.
"""
User = env['res.users']
created_users = User
for row in valid_rows:
try:
user = User.sudo().create({
'name': row['name'],
'login': row['email'],
'email': row['email'],
'phone': row.get('phone', ''),
'password': row['national_id'],
'account_source': 'entity_bulk_upload',
'account_status': 'activated',
'entity_id': entity_id,
})
created_users |= user
except Exception:
_logger.exception("Failed to create account for %s", row.get('email'))
return created_users
def send_credentials(self, env, user_ids):
"""Send credential emails to newly created users.
Args:
env: Odoo environment.
user_ids: list of int, res.users IDs.
"""
Mail = env['mail.mail']
for user in env['res.users'].sudo().browse(user_ids):
mail_values = {
'subject': 'Your EnCoach Account Credentials',
'email_from': env.company.email or 'noreply@encoach.com',
'email_to': user.email,
'body_html': (
f'<p>Dear {user.name},</p>'
f'<p>Your EnCoach account has been created.</p>'
f'<p>Login: {user.login}</p>'
f'<p>Password: Your National ID</p>'
f'<p>Please change your password after first login.</p>'
),
}
mail = Mail.sudo().create(mail_values)
mail.send()
def resend_credentials(self, env, user_id):
"""Resend credential email for a single user.
Args:
env: Odoo environment.
user_id: int, res.users ID.
"""
self.send_credentials(env, [user_id])

View File

@@ -0,0 +1,69 @@
import csv
import io
REQUIRED_COLUMNS = {'name', 'email', 'national_id', 'phone'}
class CsvParser:
def validate(self, file_content):
"""Parse CSV content and validate required columns.
Args:
file_content: raw CSV string or bytes.
Returns:
dict with 'valid_rows' (list of dicts) and 'errors' (list of str).
"""
if isinstance(file_content, bytes):
file_content = file_content.decode('utf-8-sig')
errors = []
valid_rows = []
reader = csv.DictReader(io.StringIO(file_content))
if not reader.fieldnames:
return {'valid_rows': [], 'errors': ['Empty CSV file or missing header row']}
header_set = {col.strip().lower() for col in reader.fieldnames}
missing = REQUIRED_COLUMNS - header_set
if missing:
return {
'valid_rows': [],
'errors': [f"Missing required columns: {', '.join(sorted(missing))}"],
}
for idx, row in enumerate(reader, start=2):
parsed = self.parse_row(row)
row_errors = []
if not parsed.get('name'):
row_errors.append(f"Row {idx}: 'name' is required")
if not parsed.get('email'):
row_errors.append(f"Row {idx}: 'email' is required")
if not parsed.get('national_id'):
row_errors.append(f"Row {idx}: 'national_id' is required")
if row_errors:
errors.extend(row_errors)
else:
valid_rows.append(parsed)
return {'valid_rows': valid_rows, 'errors': errors}
def parse_row(self, row):
"""Normalize a single CSV row.
Args:
row: dict from csv.DictReader.
Returns:
dict with trimmed, normalized values.
"""
normalized = {}
for key, value in row.items():
clean_key = key.strip().lower()
normalized[clean_key] = value.strip() if value else ''
return normalized

View File

@@ -0,0 +1,120 @@
import json
from odoo import models, fields, api
MODULE_SELECTION = [
('reading', 'Reading'),
('writing', 'Writing'),
('listening', 'Listening'),
('speaking', 'Speaking'),
('math', 'Math'),
('it', 'IT'),
('level', 'Level'),
('general', 'General'),
]
class EncoachExam(models.Model):
_name = 'encoach.exam'
_description = 'EnCoach Exam'
name = fields.Char(required=True)
module = fields.Selection(MODULE_SELECTION, string='Module')
subject_id = fields.Many2one('encoach.subject', string='Subject')
topic_ids = fields.Many2many(
'encoach.topic', 'encoach_exam_topic_rel', 'exam_id', 'topic_id',
string='Topics',
)
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
creator_id = fields.Many2one(
'res.users', default=lambda self: self.env.user, ondelete='set null',
)
structure_id = fields.Many2one('encoach.exam.structure', ondelete='set null')
rubric_id = fields.Many2one('encoach.exam.rubric', ondelete='set null')
exercises = fields.Text(string='Exercises (JSON)')
passages = fields.Text(string='Passages (JSON)')
is_public = fields.Boolean(default=False)
status = fields.Selection(
[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived')],
default='draft',
)
difficulty = fields.Selection(
[('easy', 'Easy'), ('medium', 'Medium'), ('hard', 'Hard'), ('mixed', 'Mixed')],
)
time_limit = fields.Integer(default=0, help='Minutes. 0 = no limit.')
total_questions = fields.Integer(compute='_compute_total_questions', store=True)
tags = fields.Char()
approval_status = fields.Selection(
[('none', 'None'), ('pending', 'Pending'),
('approved', 'Approved'), ('rejected', 'Rejected')],
default='none',
)
results_release_mode = fields.Selection([
('auto', 'Auto'),
('manual_approval', 'Manual Approval'),
], default='auto')
template_type = fields.Selection([
('ielts_academic', 'IELTS Academic'),
('ielts_general_training', 'IELTS General Training'),
('general_english', 'General English'),
('toefl', 'TOEFL'),
('step', 'STEP'),
('ic3', 'IC3'),
('custom', 'Custom'),
])
assembly_mode = fields.Selection([
('auto', 'Auto'),
('manual', 'Manual'),
('hybrid', 'Hybrid'),
])
target_band = fields.Float()
randomize = fields.Boolean(default=False)
@api.depends('exercises')
def _compute_total_questions(self):
for rec in self:
count = 0
if rec.exercises:
try:
data = json.loads(rec.exercises)
count = len(data) if isinstance(data, list) else 0
except (json.JSONDecodeError, TypeError):
pass
rec.total_questions = count
def action_publish(self):
for rec in self:
rec.status = 'published'
def action_archive(self):
for rec in self:
rec.status = 'archived'
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'module': self.module or '',
'subject_id': self.subject_id.id if self.subject_id else None,
'topic_ids': self.topic_ids.ids,
'entity_id': self.entity_id.id if self.entity_id else None,
'creator_id': self.creator_id.id if self.creator_id else None,
'structure_id': self.structure_id.id if self.structure_id else None,
'rubric_id': self.rubric_id.id if self.rubric_id else None,
'exercises': json.loads(self.exercises) if self.exercises else [],
'passages': json.loads(self.passages) if self.passages else [],
'is_public': self.is_public,
'status': self.status,
'difficulty': self.difficulty or '',
'time_limit': self.time_limit,
'total_questions': self.total_questions,
'tags': self.tags or '',
'approval_status': self.approval_status,
'results_release_mode': self.results_release_mode or 'auto',
'template_type': self.template_type or '',
'assembly_mode': self.assembly_mode or '',
'target_band': self.target_band,
'randomize': self.randomize,
}

View File

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

View File

@@ -0,0 +1,34 @@
{
'name': 'EnCoach Exam Session',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'POS-inspired full-screen exam delivery with timer, auto-save, and question renderers',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': [
'base',
'web',
'encoach_core',
'encoach_scoring',
'encoach_exam_template',
'encoach_placement',
],
'data': [
'security/ir.model.access.csv',
'views/exam_session_templates.xml',
],
'assets': {
'web.assets_frontend': [
'encoach_exam_session/static/src/css/exam_session.css',
'encoach_exam_session/static/src/js/question_renderers.js',
'encoach_exam_session/static/src/js/exam_app.js',
'encoach_exam_session/static/src/js/placement_app.js',
'encoach_exam_session/static/src/xml/question_renderers.xml',
'encoach_exam_session/static/src/xml/exam_app.xml',
'encoach_exam_session/static/src/xml/placement_app.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,30 @@
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class ExamSessionController(http.Controller):
@http.route('/exam/session/<int:attempt_id>', type='http',
auth='user', website=False)
def exam_session(self, attempt_id, **kw):
attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id)
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return request.not_found()
return request.render('encoach_exam_session.exam_session_page', {
'attempt': attempt,
'session_token': request.session.sid,
})
@http.route('/placement/test/<int:session_id>', type='http',
auth='user', website=False)
def placement_test(self, session_id, **kw):
session = request.env['encoach.cat.session'].sudo().browse(session_id)
if not session.exists() or session.student_id.id != request.env.user.id:
return request.not_found()
return request.render('encoach_exam_session.placement_session_page', {
'cat_session': session,
})

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,423 @@
/** @odoo-module */
import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
const AUTOSAVE_INTERVAL = 10000;
export class ExamApp extends Component {
static template = "encoach_exam_session.ExamApp";
static props = {};
setup() {
this.rootRef = useRef("root");
this.state = useState({
loading: true,
examData: null,
currentSectionIdx: 0,
currentQuestionIdx: 0,
answers: {},
flagged: {},
timeRemaining: 0,
totalTimeRemaining: 0,
submitted: false,
showReview: false,
showConfirmSubmit: false,
submitMessage: "",
});
this.timerInterval = null;
this.autosaveInterval = null;
onWillStart(async () => {
await this.loadExamData();
});
onMounted(() => {
this.startTimers();
this.enterFullscreen();
this.loadFromLocalStorage();
document.addEventListener("visibilitychange", this._onVisibility);
});
onWillUnmount(() => {
this.stopTimers();
document.removeEventListener("visibilitychange", this._onVisibility);
});
}
get attemptId() {
const el = document.getElementById("exam_session_app");
return el ? parseInt(el.dataset.attemptId) : 0;
}
get examId() {
const el = document.getElementById("exam_session_app");
return el ? parseInt(el.dataset.examId) : 0;
}
getToken() {
return (
document.cookie
.split(";")
.find((c) => c.trim().startsWith("session_id="))
?.split("=")[1] || ""
);
}
// ── Data Loading ──────────────────────────────────────────────────
async loadExamData() {
try {
const response = await fetch(`/api/exam/${this.examId}/session`, {
headers: { Authorization: `Bearer ${this.getToken()}` },
});
const data = await response.json();
this.state.examData = data;
this.state.timeRemaining =
data.sections?.[0]?.time_limit_sec || 3600;
this.state.totalTimeRemaining = data.total_time_sec || 7200;
if (data.saved_answers) {
for (const ans of data.saved_answers) {
this.state.answers[ans.question_id] = ans.answer;
}
}
this.state.loading = false;
} catch (e) {
console.error("Failed to load exam data:", e);
}
}
// ── Timers ────────────────────────────────────────────────────────
startTimers() {
this.timerInterval = setInterval(() => {
if (this.state.timeRemaining > 0) {
this.state.timeRemaining--;
this.state.totalTimeRemaining--;
} else {
this.autoSubmitSection();
}
}, 1000);
this.autosaveInterval = setInterval(
() => this.autosave(),
AUTOSAVE_INTERVAL
);
}
stopTimers() {
if (this.timerInterval) clearInterval(this.timerInterval);
if (this.autosaveInterval) clearInterval(this.autosaveInterval);
}
enterFullscreen() {
const el = document.documentElement;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => {});
}
}
formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
get timerClass() {
if (this.state.timeRemaining < 60) return "ec-timer ec-timer--critical";
if (this.state.timeRemaining < 300) return "ec-timer ec-timer--warning";
return "ec-timer";
}
// ── Section / Question Navigation ─────────────────────────────────
get currentSection() {
return this.state.examData?.sections?.[this.state.currentSectionIdx];
}
get currentQuestion() {
return this.currentSection?.questions?.[this.state.currentQuestionIdx];
}
get totalQuestions() {
return this.currentSection?.questions?.length || 0;
}
get answeredCount() {
const section = this.currentSection;
if (!section) return 0;
return section.questions.filter(
(q) => this.state.answers[q.id] !== undefined
).length;
}
get sections() {
return this.state.examData?.sections || [];
}
get questionDots() {
const section = this.currentSection;
if (!section) return [];
return section.questions.map((q, idx) => ({
idx,
id: q.id,
answered: this.state.answers[q.id] !== undefined,
flagged: !!this.state.flagged[q.id],
current: idx === this.state.currentQuestionIdx,
}));
}
get hasPrev() {
return this.state.currentQuestionIdx > 0;
}
get hasNext() {
return this.state.currentQuestionIdx < this.totalQuestions - 1;
}
get isLastSection() {
return (
this.state.currentSectionIdx >=
(this.state.examData?.sections?.length || 1) - 1
);
}
nextQuestion() {
if (this.state.currentQuestionIdx < this.totalQuestions - 1) {
this.state.currentQuestionIdx++;
}
}
prevQuestion() {
if (this.state.currentQuestionIdx > 0) {
this.state.currentQuestionIdx--;
}
}
goToQuestion(idx) {
this.state.currentQuestionIdx = idx;
this.state.showReview = false;
}
switchSection(idx) {
this.state.currentSectionIdx = idx;
this.state.currentQuestionIdx = 0;
const section = this.state.examData?.sections?.[idx];
if (section?.time_limit_sec) {
this.state.timeRemaining = section.time_limit_sec;
}
}
nextSection() {
const sections = this.state.examData?.sections || [];
if (this.state.currentSectionIdx < sections.length - 1) {
this.state.currentSectionIdx++;
this.state.currentQuestionIdx = 0;
this.state.timeRemaining =
this.currentSection?.time_limit_sec || 3600;
}
}
// ── Answer Management ─────────────────────────────────────────────
setAnswer(questionId, value) {
this.state.answers[questionId] = value;
this.saveToLocalStorage();
}
onAnswer(ev) {
if (ev.detail) {
this.setAnswer(ev.detail.questionId, ev.detail.value);
}
}
toggleFlag() {
const q = this.currentQuestion;
if (!q) return;
this.state.flagged[q.id] = !this.state.flagged[q.id];
}
get isCurrentFlagged() {
const q = this.currentQuestion;
return q ? !!this.state.flagged[q.id] : false;
}
// ── LocalStorage Persistence ──────────────────────────────────────
saveToLocalStorage() {
try {
localStorage.setItem(
`exam_${this.attemptId}`,
JSON.stringify({
answers: this.state.answers,
flagged: this.state.flagged,
currentSectionIdx: this.state.currentSectionIdx,
currentQuestionIdx: this.state.currentQuestionIdx,
timeRemaining: this.state.timeRemaining,
})
);
} catch (e) {
// storage full or unavailable
}
}
loadFromLocalStorage() {
try {
const saved = localStorage.getItem(`exam_${this.attemptId}`);
if (saved) {
const data = JSON.parse(saved);
Object.assign(this.state.answers, data.answers || {});
Object.assign(this.state.flagged, data.flagged || {});
}
} catch (e) {
// corrupt data
}
}
// ── Server Autosave ───────────────────────────────────────────────
async autosave() {
try {
const answers = Object.entries(this.state.answers).map(
([qid, ans]) => ({
question_id: parseInt(qid),
answer: ans,
})
);
await fetch(`/api/exam/${this.examId}/autosave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
attempt_id: this.attemptId,
answers,
current_section: this.currentSection?.skill || "",
time_remaining_sec: this.state.timeRemaining,
}),
});
} catch (e) {
console.warn("Autosave failed:", e);
}
}
// ── Visibility tracking (tab-switch guard) ────────────────────────
_onVisibility = () => {
if (document.visibilityState === "visible") {
this.autosave();
}
};
// ── Submit Flow ───────────────────────────────────────────────────
autoSubmitSection() {
const sections = this.state.examData?.sections || [];
if (this.state.currentSectionIdx < sections.length - 1) {
this.nextSection();
} else {
this.submitExam();
}
}
showReviewPanel() {
this.state.showReview = true;
}
hideReviewPanel() {
this.state.showReview = false;
}
confirmSubmit() {
this.state.showConfirmSubmit = true;
}
cancelSubmit() {
this.state.showConfirmSubmit = false;
}
async submitExam() {
this.stopTimers();
this.state.submitted = true;
this.state.showConfirmSubmit = false;
this.state.submitMessage = "Submitting your answers...";
try {
const answers = Object.entries(this.state.answers).map(
([qid, ans]) => ({
question_id: parseInt(qid),
answer: ans,
})
);
const response = await fetch(`/api/exam/${this.examId}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
attempt_id: this.attemptId,
answers,
}),
});
await response.json();
localStorage.removeItem(`exam_${this.attemptId}`);
this.state.submitMessage =
"Exam submitted successfully! Redirecting...";
setTimeout(() => {
window.location.href = `/my/exam/${this.attemptId}/results`;
}, 2000);
} catch (e) {
console.error("Submit failed:", e);
this.state.submitMessage =
"Submission failed. Please try again.";
this.state.submitted = false;
}
}
// ── Review summary helpers ────────────────────────────────────────
get reviewSections() {
const sections = this.state.examData?.sections || [];
return sections.map((section, sIdx) => ({
...section,
sIdx,
questions: section.questions.map((q, qIdx) => ({
...q,
qIdx,
sIdx,
answered: this.state.answers[q.id] !== undefined,
flagged: !!this.state.flagged[q.id],
})),
}));
}
get totalAnswered() {
return Object.keys(this.state.answers).length;
}
get totalAllQuestions() {
const sections = this.state.examData?.sections || [];
return sections.reduce((sum, s) => sum + (s.questions?.length || 0), 0);
}
get totalFlagged() {
return Object.values(this.state.flagged).filter(Boolean).length;
}
reviewGoTo(sIdx, qIdx) {
this.state.currentSectionIdx = sIdx;
this.state.currentQuestionIdx = qIdx;
this.state.showReview = false;
}
}
// ── Auto-mount on page load ───────────────────────────────────────────
function mountExamApp() {
const target = document.getElementById("exam_session_app");
if (target) {
const { mount } = owl;
mount(ExamApp, target);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountExamApp);
} else {
mountExamApp();
}

View File

@@ -0,0 +1,199 @@
/** @odoo-module */
import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl";
const SEM_THRESHOLD = 0.3;
const MAX_QUESTIONS = 40;
export class PlacementApp extends Component {
static template = "encoach_exam_session.PlacementApp";
static props = {};
setup() {
this.rootRef = useRef("root");
this.state = useState({
loading: true,
sessionId: 0,
currentQuestion: null,
answer: null,
questionsAnswered: 0,
theta: 0.0,
sem: 1.0,
completed: false,
cefrLevel: null,
submitting: false,
error: null,
});
onWillStart(async () => {
await this.startSession();
});
onMounted(() => {
this.enterFullscreen();
});
}
get sessionIdFromDom() {
const el = document.getElementById("placement_session_app");
return el ? parseInt(el.dataset.sessionId) : 0;
}
enterFullscreen() {
const el = document.documentElement;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => {});
}
}
// ── Session Start ─────────────────────────────────────────────
async startSession() {
try {
const domSessionId = this.sessionIdFromDom;
if (domSessionId) {
this.state.sessionId = domSessionId;
await this.loadCurrentQuestion();
} else {
const response = await fetch("/api/placement/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await response.json();
this.state.sessionId = data.session_id;
this.state.currentQuestion = data.first_question;
}
this.state.loading = false;
} catch (e) {
this.state.error = "Failed to start placement test.";
this.state.loading = false;
}
}
async loadCurrentQuestion() {
try {
const response = await fetch(
`/api/placement/current?session_id=${this.state.sessionId}`
);
const data = await response.json();
if (data.question) {
this.state.currentQuestion = data.question;
this.state.questionsAnswered = data.questions_answered || 0;
this.state.theta = data.theta || 0.0;
this.state.sem = data.sem || 1.0;
}
} catch (e) {
console.error("Failed to load current question:", e);
}
}
// ── Answer Handling ───────────────────────────────────────────
setAnswer(questionId, value) {
this.state.answer = value;
}
get hasAnswer() {
return this.state.answer !== null && this.state.answer !== undefined && this.state.answer !== "";
}
async submitAnswer() {
if (!this.hasAnswer || this.state.submitting) return;
this.state.submitting = true;
try {
const response = await fetch("/api/placement/answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: this.state.sessionId,
question_id: this.state.currentQuestion.id,
answer: this.state.answer,
}),
});
const data = await response.json();
this.state.theta = data.new_theta;
this.state.sem = data.new_sem;
this.state.questionsAnswered++;
if (data.completed) {
this.state.completed = true;
this.state.cefrLevel = data.cefr_level;
} else {
this.state.currentQuestion = data.next_question;
this.state.answer = null;
}
} catch (e) {
this.state.error = "Failed to submit answer. Please try again.";
} finally {
this.state.submitting = false;
}
}
// ── Progress Helpers ──────────────────────────────────────────
get progressPercent() {
const semProgress = Math.max(0, (1.0 - this.state.sem) / (1.0 - SEM_THRESHOLD));
const questionProgress = this.state.questionsAnswered / MAX_QUESTIONS;
return Math.min(Math.max(semProgress, questionProgress) * 100, 100);
}
get progressStyle() {
return `width: ${this.progressPercent}%`;
}
get questionType() {
return this.state.currentQuestion?.question_type || "mcq";
}
get questionOptions() {
const opts = this.state.currentQuestion?.options;
if (Array.isArray(opts)) {
return opts.map((o, i) => ({
key: o.key || String.fromCharCode(65 + i),
label: o.label || o.text || o,
}));
}
if (opts && typeof opts === "object") {
return Object.entries(opts).map(([key, label]) => ({ key, label }));
}
return [];
}
selectOption(key) {
this.state.answer = key;
}
get cefrLabel() {
const map = {
pre_a1: "Pre-A1",
a1: "A1",
a2: "A2",
b1: "B1",
b2: "B2",
c1: "C1",
c2: "C2",
};
return map[this.state.cefrLevel] || this.state.cefrLevel?.toUpperCase() || "";
}
goToDashboard() {
window.location.href = "/my/dashboard";
}
}
// ── Auto-mount ────────────────────────────────────────────────────
function mountPlacementApp() {
const target = document.getElementById("placement_session_app");
if (target) {
const { mount } = owl;
mount(PlacementApp, target);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountPlacementApp);
} else {
mountPlacementApp();
}

View File

@@ -0,0 +1,345 @@
/** @odoo-module */
import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";
// ═══════════════════════════════════════════════════════════════════════
// ExamMCQ — Single-choice radio buttons
// ═══════════════════════════════════════════════════════════════════════
export class ExamMCQ extends Component {
static template = "encoach_exam_session.ExamMCQ";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
selectOption(optionKey) {
this.props.onAnswer(this.props.question.id, optionKey);
}
get options() {
const opts = this.props.question.options;
if (Array.isArray(opts)) {
return opts.map((o, i) => ({
key: o.key || String.fromCharCode(65 + i),
label: o.label || o.text || o,
}));
}
if (opts && typeof opts === "object") {
return Object.entries(opts).map(([key, label]) => ({ key, label }));
}
return [];
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamMCQMulti — Multi-choice checkboxes
// ═══════════════════════════════════════════════════════════════════════
export class ExamMCQMulti extends Component {
static template = "encoach_exam_session.ExamMCQMulti";
static props = {
question: Object,
answer: { type: [Array, { value: undefined }], optional: true },
onAnswer: Function,
};
toggleOption(optionKey) {
const current = Array.isArray(this.props.answer)
? [...this.props.answer]
: [];
const idx = current.indexOf(optionKey);
if (idx >= 0) {
current.splice(idx, 1);
} else {
current.push(optionKey);
}
this.props.onAnswer(this.props.question.id, current);
}
isChecked(optionKey) {
return Array.isArray(this.props.answer) && this.props.answer.includes(optionKey);
}
get options() {
const opts = this.props.question.options;
if (Array.isArray(opts)) {
return opts.map((o, i) => ({
key: o.key || String.fromCharCode(65 + i),
label: o.label || o.text || o,
}));
}
if (opts && typeof opts === "object") {
return Object.entries(opts).map(([key, label]) => ({ key, label }));
}
return [];
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamGapFill — Inline text inputs within a passage
// ═══════════════════════════════════════════════════════════════════════
export class ExamGapFill extends Component {
static template = "encoach_exam_session.ExamGapFill";
static props = {
question: Object,
answer: { type: [Object, { value: undefined }], optional: true },
onAnswer: Function,
};
onGapInput(gapIdx, ev) {
const current = this.props.answer ? { ...this.props.answer } : {};
current[gapIdx] = ev.target.value;
this.props.onAnswer(this.props.question.id, current);
}
get gaps() {
return this.props.question.gaps || [];
}
gapValue(gapIdx) {
return this.props.answer?.[gapIdx] || "";
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamTFNG — True / False / Not Given selector
// ═══════════════════════════════════════════════════════════════════════
export class ExamTFNG extends Component {
static template = "encoach_exam_session.ExamTFNG";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
select(value) {
this.props.onAnswer(this.props.question.id, value);
}
get choices() {
return [
{ key: "true", label: "True" },
{ key: "false", label: "False" },
{ key: "not_given", label: "Not Given" },
];
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamAudioRecorder — Record audio (speaking tasks)
// ═══════════════════════════════════════════════════════════════════════
export class ExamAudioRecorder extends Component {
static template = "encoach_exam_session.ExamAudioRecorder";
static props = {
question: Object,
answer: { type: [String, Object, { value: undefined }], optional: true },
onAnswer: Function,
};
setup() {
this.state = useState({
recording: false,
recordedUrl: null,
duration: 0,
error: null,
});
this.mediaRecorder = null;
this.chunks = [];
this.durationInterval = null;
}
async startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus",
});
this.chunks = [];
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: "audio/webm" });
this.state.recordedUrl = URL.createObjectURL(blob);
this.props.onAnswer(this.props.question.id, {
type: "audio",
blob_url: this.state.recordedUrl,
duration: this.state.duration,
});
stream.getTracks().forEach((t) => t.stop());
};
this.mediaRecorder.start();
this.state.recording = true;
this.state.duration = 0;
this.durationInterval = setInterval(() => {
this.state.duration++;
}, 1000);
} catch (e) {
this.state.error = "Microphone access denied. Please allow microphone access.";
}
}
stopRecording() {
if (this.mediaRecorder && this.state.recording) {
this.mediaRecorder.stop();
this.state.recording = false;
if (this.durationInterval) clearInterval(this.durationInterval);
}
}
formatDuration(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamRichEditor — Writing task with rich text
// ═══════════════════════════════════════════════════════════════════════
export class ExamRichEditor extends Component {
static template = "encoach_exam_session.ExamRichEditor";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
setup() {
this.editorRef = useRef("editor");
this.state = useState({
wordCount: this._countWords(this.props.answer || ""),
});
}
onInput(ev) {
const text = ev.target.value || "";
this.state.wordCount = this._countWords(text);
this.props.onAnswer(this.props.question.id, text);
}
_countWords(text) {
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
}
get minWords() {
return this.props.question.min_words || 150;
}
get maxWords() {
return this.props.question.max_words || 300;
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamReadingPassage — Scrollable passage display
// ═══════════════════════════════════════════════════════════════════════
export class ExamReadingPassage extends Component {
static template = "encoach_exam_session.ExamReadingPassage";
static props = {
passage: { type: [Object, String, { value: undefined }], optional: true },
};
get passageTitle() {
if (typeof this.props.passage === "object") {
return this.props.passage?.title || "Reading Passage";
}
return "Reading Passage";
}
get passageContent() {
if (typeof this.props.passage === "object") {
return this.props.passage?.content || "";
}
return this.props.passage || "";
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamNumericalInput — Number input with optional tolerance
// ═══════════════════════════════════════════════════════════════════════
export class ExamNumericalInput extends Component {
static template = "encoach_exam_session.ExamNumericalInput";
static props = {
question: Object,
answer: { type: [Number, String, { value: undefined }], optional: true },
onAnswer: Function,
};
onInput(ev) {
const val = ev.target.value;
this.props.onAnswer(this.props.question.id, val);
}
get tolerance() {
return this.props.question.tolerance;
}
get unit() {
return this.props.question.unit || "";
}
}
// ═══════════════════════════════════════════════════════════════════════
// ExamCodeEditor — Code editing textarea
// ═══════════════════════════════════════════════════════════════════════
export class ExamCodeEditor extends Component {
static template = "encoach_exam_session.ExamCodeEditor";
static props = {
question: Object,
answer: { type: [String, { value: undefined }], optional: true },
onAnswer: Function,
};
setup() {
this.editorRef = useRef("codeEditor");
onMounted(() => {
if (this.editorRef.el) {
this.editorRef.el.addEventListener("keydown", this._onKeydown);
}
});
onWillUnmount(() => {
if (this.editorRef.el) {
this.editorRef.el.removeEventListener("keydown", this._onKeydown);
}
});
}
_onKeydown = (ev) => {
if (ev.key === "Tab") {
ev.preventDefault();
const ta = ev.target;
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + " " + ta.value.substring(end);
ta.selectionStart = ta.selectionEnd = start + 4;
this.props.onAnswer(this.props.question.id, ta.value);
}
};
onInput(ev) {
this.props.onAnswer(this.props.question.id, ev.target.value);
}
get language() {
return this.props.question.language || "python";
}
get starterCode() {
return this.props.question.starter_code || "";
}
}

View File

@@ -0,0 +1,302 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ═══════════════════════════════════════════════════════════════
ExamApp — Main full-screen exam orchestrator
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamApp">
<div class="ec-exam-fullscreen" t-ref="root">
<!-- Loading State -->
<t t-if="state.loading">
<div class="ec-loading">
<div class="ec-spinner"/>
<div class="ec-loading-text">Loading your exam...</div>
</div>
</t>
<!-- Submitted State -->
<t t-elif="state.submitted">
<div class="ec-submitted-overlay">
<div class="ec-submitted-icon"></div>
<div class="ec-submitted-text" t-esc="state.submitMessage"/>
</div>
</t>
<!-- Exam Active State -->
<t t-else="">
<!-- ── Top Bar ──────────────────────────────────────── -->
<div class="ec-topbar">
<div class="ec-topbar-title"
t-esc="state.examData?.title or 'Exam Session'"/>
<!-- Section tabs -->
<div class="ec-section-tabs">
<t t-foreach="sections" t-as="section" t-key="section_index">
<button t-att-class="'ec-section-tab' + (section_index === state.currentSectionIdx ? ' ec-section-tab--active' : '')"
t-on-click="() => this.switchSection(section_index)">
<t t-esc="section.name or section.skill or ('Section ' + (section_index + 1))"/>
</button>
</t>
</div>
<div class="ec-topbar-spacer"/>
<!-- Progress -->
<div class="ec-progress-info">
<t t-esc="answeredCount"/> / <t t-esc="totalQuestions"/> answered
</div>
<!-- Timer -->
<div t-att-class="timerClass">
<span class="ec-timer-icon"></span>
<span t-esc="formatTime(state.timeRemaining)"/>
</div>
</div>
<!-- ── Content Area ─────────────────────────────────── -->
<div class="ec-content">
<!-- Passage panel (only if current question has a passage) -->
<t t-if="currentQuestion?.passage">
<div class="ec-passage-panel">
<div class="ec-passage-title"
t-esc="currentQuestion.passage.title or 'Reading Passage'"/>
<div class="ec-passage-body"
t-esc="currentQuestion.passage.content or currentQuestion.passage"/>
</div>
</t>
<!-- Question panel -->
<div t-att-class="'ec-question-panel' + (currentQuestion?.passage ? '' : ' ec-question-panel--full')">
<t t-if="currentQuestion">
<!-- Question header -->
<div class="ec-question-header">
<div class="ec-question-number"
t-esc="state.currentQuestionIdx + 1"/>
<div class="ec-question-type"
t-esc="currentQuestion.question_type or 'question'"/>
</div>
<!-- Question stem -->
<div class="ec-question-stem"
t-esc="currentQuestion.stem"/>
<!-- Question Renderer (dispatched by type) -->
<t t-if="currentQuestion.question_type === 'mcq'">
<ExamMCQ question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'mcq_multi'">
<ExamMCQMulti question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'gap_fill'">
<ExamGapFill question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'tfng'">
<ExamTFNG question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'audio_recording'">
<ExamAudioRecorder question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'writing'">
<ExamRichEditor question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'numerical'">
<ExamNumericalInput question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<t t-elif="currentQuestion.question_type === 'code'">
<ExamCodeEditor question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
<!-- Fallback: plain MCQ -->
<t t-else="">
<ExamMCQ question="currentQuestion"
answer="state.answers[currentQuestion.id]"
onAnswer.bind="setAnswer"/>
</t>
</t>
</div>
</div>
<!-- ── Bottom Bar ───────────────────────────────────── -->
<div class="ec-bottombar">
<button class="ec-nav-btn"
t-att-disabled="!hasPrev"
t-on-click="prevQuestion">
← Prev
</button>
<button class="ec-nav-btn"
t-att-disabled="!hasNext"
t-on-click="nextQuestion">
Next →
</button>
<button t-att-class="'ec-flag-btn' + (isCurrentFlagged ? ' ec-flag-btn--active' : '')"
t-on-click="toggleFlag">
<span></span>
<span t-if="isCurrentFlagged">Flagged</span>
<span t-else="">Flag</span>
</button>
<div class="ec-dots">
<t t-foreach="questionDots" t-as="dot" t-key="dot.idx">
<div t-att-class="'ec-dot'
+ (dot.current ? ' ec-dot--current' : '')
+ (dot.answered ? ' ec-dot--answered' : '')
+ (dot.flagged ? ' ec-dot--flagged' : '')"
t-on-click="() => this.goToQuestion(dot.idx)">
<t t-esc="dot.idx + 1"/>
</div>
</t>
</div>
<div class="ec-bottombar-spacer"/>
<button class="ec-review-btn" t-on-click="showReviewPanel">
Review All
</button>
<t t-if="isLastSection">
<button class="ec-submit-btn" t-on-click="confirmSubmit">
Submit Exam
</button>
</t>
<t t-else="">
<button class="ec-submit-btn" t-on-click="nextSection">
Next Section →
</button>
</t>
</div>
<!-- ── Review Overlay ───────────────────────────────── -->
<t t-if="state.showReview">
<div class="ec-review-overlay" t-on-click.self="hideReviewPanel">
<div class="ec-review-panel">
<div class="ec-review-header">
<div class="ec-review-title">Review Your Answers</div>
<button class="ec-review-close"
t-on-click="hideReviewPanel"></button>
</div>
<!-- Stats -->
<div class="ec-review-stats">
<div class="ec-review-stat">
<div class="ec-review-stat-val"
t-esc="totalAnswered"/>
<div class="ec-review-stat-label">Answered</div>
</div>
<div class="ec-review-stat">
<div class="ec-review-stat-val"
t-esc="totalAllQuestions - totalAnswered"/>
<div class="ec-review-stat-label">Unanswered</div>
</div>
<div class="ec-review-stat">
<div class="ec-review-stat-val"
t-esc="totalFlagged"/>
<div class="ec-review-stat-label">Flagged</div>
</div>
</div>
<!-- Legend -->
<div class="ec-review-legend">
<div class="ec-legend-item">
<div class="ec-legend-dot ec-legend-dot--answered"/>
<span>Answered</span>
</div>
<div class="ec-legend-item">
<div class="ec-legend-dot ec-legend-dot--flagged"/>
<span>Flagged</span>
</div>
<div class="ec-legend-item">
<div class="ec-legend-dot ec-legend-dot--unanswered"/>
<span>Unanswered</span>
</div>
</div>
<!-- Section grids -->
<t t-foreach="reviewSections" t-as="section" t-key="section.sIdx">
<div class="ec-review-section">
<div class="ec-review-section-title"
t-esc="section.name or section.skill or ('Section ' + (section.sIdx + 1))"/>
<div class="ec-review-grid">
<t t-foreach="section.questions" t-as="q" t-key="q.id">
<div t-att-class="'ec-review-cell'
+ (q.answered ? ' ec-review-cell--answered' : ' ec-review-cell--unanswered')
+ (q.flagged ? ' ec-review-cell--flagged' : '')"
t-on-click="() => this.reviewGoTo(q.sIdx, q.qIdx)">
<t t-esc="q.qIdx + 1"/>
</div>
</t>
</div>
</div>
</t>
<div class="ec-review-footer">
<button class="ec-modal-cancel"
t-on-click="hideReviewPanel">
Back to Exam
</button>
<button class="ec-modal-confirm"
t-on-click="confirmSubmit">
Submit Exam
</button>
</div>
</div>
</div>
</t>
<!-- ── Submit Confirmation Modal ────────────────────── -->
<t t-if="state.showConfirmSubmit">
<div class="ec-modal-overlay" t-on-click.self="cancelSubmit">
<div class="ec-modal">
<div class="ec-modal-icon">📝</div>
<div class="ec-modal-title">Submit Exam?</div>
<div class="ec-modal-body">
You have answered <b t-esc="totalAnswered"/> out of
<b t-esc="totalAllQuestions"/> questions.
<t t-if="totalAllQuestions - totalAnswered > 0">
<br/>
<span style="color: var(--ec-warning);">
<t t-esc="totalAllQuestions - totalAnswered"/>
question(s) are still unanswered.
</span>
</t>
<br/><br/>
Once submitted, you cannot change your answers.
</div>
<div class="ec-modal-actions">
<button class="ec-modal-cancel"
t-on-click="cancelSubmit">
Go Back
</button>
<button class="ec-modal-confirm"
t-on-click="submitExam">
Submit Now
</button>
</div>
</div>
</div>
</t>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ═══════════════════════════════════════════════════════════════
PlacementApp — CAT adaptive placement test
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.PlacementApp">
<div class="ec-exam-fullscreen" t-ref="root">
<!-- Loading -->
<t t-if="state.loading">
<div class="ec-loading">
<div class="ec-spinner"/>
<div class="ec-loading-text">Preparing your placement test...</div>
</div>
</t>
<!-- Error -->
<t t-elif="state.error and !state.currentQuestion">
<div class="ec-loading">
<div class="ec-loading-text" style="color: var(--ec-danger);"
t-esc="state.error"/>
</div>
</t>
<!-- Completed — Show results -->
<t t-elif="state.completed">
<div class="ec-topbar">
<div class="ec-topbar-title">Placement Test — Complete</div>
</div>
<div class="ec-placement-center">
<div class="ec-placement-card">
<div class="ec-placement-results">
<div class="ec-placement-results-icon"></div>
<div class="ec-placement-results-title">
Placement Complete!
</div>
<div class="ec-placement-results-level"
t-esc="cefrLabel"/>
<div class="ec-placement-results-desc">
Based on your responses, you have been placed at
the <b t-esc="cefrLabel"/> level.
Your personalized learning path is now ready.
</div>
<button class="ec-placement-results-btn"
t-on-click="goToDashboard">
Go to Dashboard
</button>
</div>
</div>
</div>
</t>
<!-- Active Question -->
<t t-else="">
<!-- Top bar with progress -->
<div class="ec-topbar">
<div class="ec-topbar-title">Placement Test</div>
<div class="ec-placement-progress">
<div class="ec-progress-bar-container">
<div class="ec-progress-bar-fill"
t-att-style="progressStyle"/>
</div>
</div>
<div class="ec-placement-info">
Question <t t-esc="state.questionsAnswered + 1"/>
</div>
</div>
<!-- Question card -->
<div class="ec-placement-center">
<div class="ec-placement-card" t-if="state.currentQuestion">
<div class="ec-placement-card-header">
<div class="ec-question-type"
t-esc="state.currentQuestion.question_type or 'question'"/>
<div class="ec-question-type"
t-esc="state.currentQuestion.skill or ''"/>
</div>
<div class="ec-placement-card-body">
<!-- Stem -->
<div class="ec-question-stem"
t-esc="state.currentQuestion.stem"/>
<!-- MCQ options (default for placement) -->
<t t-if="questionType === 'mcq' or questionType === 'multiple_choice'">
<div class="ec-option-list">
<t t-foreach="questionOptions" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (state.answer === opt.key ? ' ec-option--selected' : '')"
t-on-click="() => this.selectOption(opt.key)">
<div class="ec-option-radio"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- TFNG -->
<t t-elif="questionType === 'tfng'">
<div class="ec-tfng-options">
<div t-att-class="'ec-tfng-btn' + (state.answer === 'true' ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.selectOption('true')">True</div>
<div t-att-class="'ec-tfng-btn' + (state.answer === 'false' ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.selectOption('false')">False</div>
<div t-att-class="'ec-tfng-btn' + (state.answer === 'not_given' ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.selectOption('not_given')">Not Given</div>
</div>
</t>
<!-- Gap fill -->
<t t-elif="questionType === 'gap_fill'">
<input type="text"
class="ec-gap-input"
t-att-value="state.answer or ''"
t-on-input="(ev) => this.state.answer = ev.target.value"
placeholder="Type your answer..."/>
</t>
<!-- Fallback: text input -->
<t t-else="">
<div class="ec-option-list">
<t t-foreach="questionOptions" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (state.answer === opt.key ? ' ec-option--selected' : '')"
t-on-click="() => this.selectOption(opt.key)">
<div class="ec-option-radio"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- Error message -->
<t t-if="state.error">
<div style="color: var(--ec-danger); margin-top: 12px; font-size: 14px;"
t-esc="state.error"/>
</t>
</div>
<div class="ec-placement-actions">
<button class="ec-placement-next"
t-att-disabled="!hasAnswer or state.submitting"
t-on-click="submitAnswer">
<t t-if="state.submitting">Submitting...</t>
<t t-else="">Next Question →</t>
</button>
</div>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,188 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ═══════════════════════════════════════════════════════════════
ExamMCQ — Single-choice radio buttons
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamMCQ">
<div class="ec-option-list">
<t t-foreach="options" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (props.answer === opt.key ? ' ec-option--selected' : '')"
t-on-click="() => this.selectOption(opt.key)">
<div class="ec-option-radio"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamMCQMulti — Multi-choice checkboxes
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamMCQMulti">
<div class="ec-option-list">
<t t-foreach="options" t-as="opt" t-key="opt.key">
<div t-att-class="'ec-option' + (this.isChecked(opt.key) ? ' ec-option--selected' : '')"
t-on-click="() => this.toggleOption(opt.key)">
<div class="ec-option-checkbox"/>
<span class="ec-option-key" t-esc="opt.key + '.'"/>
<span class="ec-option-label" t-esc="opt.label"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamGapFill — Inline text inputs
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamGapFill">
<div class="ec-gap-fill">
<t t-foreach="gaps" t-as="gap" t-key="gap_index">
<div class="ec-gap-row">
<span class="ec-gap-label" t-esc="(gap_index + 1) + '.'"/>
<input type="text"
class="ec-gap-input"
t-att-placeholder="gap.hint or 'Type your answer...'"
t-att-value="this.gapValue(gap_index)"
t-on-input="(ev) => this.onGapInput(gap_index, ev)"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamTFNG — True / False / Not Given selector
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamTFNG">
<div class="ec-tfng-options">
<t t-foreach="choices" t-as="choice" t-key="choice.key">
<div t-att-class="'ec-tfng-btn' + (props.answer === choice.key ? ' ec-tfng-btn--selected' : '')"
t-on-click="() => this.select(choice.key)">
<t t-esc="choice.label"/>
</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamAudioRecorder — Record audio (speaking tasks)
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamAudioRecorder">
<div class="ec-audio-recorder">
<t t-if="state.error">
<div class="ec-audio-error" t-esc="state.error"/>
</t>
<t t-if="!state.recording and !state.recordedUrl">
<button class="ec-record-btn ec-record-btn--start"
t-on-click="startRecording">
🎙
</button>
<div class="ec-audio-hint">Click to start recording</div>
</t>
<t t-if="state.recording">
<div class="ec-audio-duration"
t-esc="formatDuration(state.duration)"/>
<button class="ec-record-btn ec-record-btn--stop"
t-on-click="stopRecording">
</button>
<div class="ec-audio-hint">Recording... Click to stop</div>
</t>
<t t-if="state.recordedUrl and !state.recording">
<div class="ec-audio-duration"
t-esc="formatDuration(state.duration)"/>
<audio class="ec-audio-playback"
controls=""
t-att-src="state.recordedUrl"/>
<button class="ec-record-btn ec-record-btn--start"
t-on-click="startRecording">
🎙
</button>
<div class="ec-audio-hint">Click to re-record</div>
</t>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamRichEditor — Writing task editor
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamRichEditor">
<div class="ec-rich-editor">
<textarea class="ec-editor-textarea"
t-ref="editor"
t-att-value="props.answer or ''"
t-on-input="onInput"
placeholder="Write your response here..."/>
<div class="ec-editor-footer">
<span t-att-class="'ec-word-count'
+ (state.wordCount &lt; minWords ? ' ec-word-count--low' : '')
+ (state.wordCount >= minWords and state.wordCount &lt;= maxWords ? ' ec-word-count--ok' : '')
+ (state.wordCount > maxWords ? ' ec-word-count--over' : '')">
<t t-esc="state.wordCount"/> words
</span>
<span class="ec-word-range">
Target: <t t-esc="minWords"/><t t-esc="maxWords"/> words
</span>
</div>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamReadingPassage — Scrollable passage display
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamReadingPassage">
<div class="ec-passage-panel">
<div class="ec-passage-title" t-esc="passageTitle"/>
<div class="ec-passage-body" t-esc="passageContent"/>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamNumericalInput — Number input with tolerance
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamNumericalInput">
<div>
<div class="ec-numerical-group">
<input type="number"
class="ec-numerical-input"
step="any"
t-att-value="props.answer or ''"
t-on-input="onInput"
placeholder="Enter value"/>
<span t-if="unit" class="ec-numerical-unit" t-esc="unit"/>
</div>
<div t-if="tolerance" class="ec-numerical-tolerance">
Accepted tolerance: ± <t t-esc="tolerance"/>
</div>
</div>
</t>
<!-- ═══════════════════════════════════════════════════════════════
ExamCodeEditor — Code editing textarea
═══════════════════════════════════════════════════════════════ -->
<t t-name="encoach_exam_session.ExamCodeEditor">
<div class="ec-code-editor">
<div class="ec-code-lang" t-esc="language"/>
<textarea class="ec-code-textarea"
t-ref="codeEditor"
t-att-value="props.answer or starterCode"
t-on-input="onInput"
placeholder="Write your code here..."
spellcheck="false"/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Full-screen exam delivery page (no header/footer) -->
<template id="exam_session_page" name="Exam Session">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="exam_session_app"
t-att-data-attempt-id="attempt.id"
t-att-data-exam-id="attempt.exam_id.id"
t-att-data-session-token="session_token"
class="ec-exam-fullscreen"/>
</t>
</template>
<!-- Full-screen placement CAT test page (no header/footer) -->
<template id="placement_session_page" name="Placement Session">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="placement_session_app"
t-att-data-session-id="cat_session.id"
t-att-data-student-id="cat_session.student_id.id"
class="ec-exam-fullscreen"/>
</t>
</template>
</odoo>

View File

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

View File

@@ -0,0 +1,39 @@
{
'name': 'EnCoach Exam Template',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Exam templates, content pool (passages, audio, questions, prompts, cards, rubrics)',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_taxonomy'],
'data': [
'security/ir.model.access.csv',
'data/ielts_templates.xml',
'data/sample_passages.xml',
'data/sample_questions.xml',
'views/exam_template_views.xml',
'views/passage_views.xml',
'views/audio_file_views.xml',
'views/question_views.xml',
'views/writing_prompt_views.xml',
'views/speaking_card_views.xml',
'views/rubric_views.xml',
'views/exam_custom_views.xml',
'views/exam_assignment_views.xml',
'views/content_pool_action.xml',
'views/exam_validation_action.xml',
'views/exam_template_menus.xml',
],
'assets': {
'web.assets_backend': [
'encoach_exam_template/static/src/js/content_pool.js',
'encoach_exam_template/static/src/xml/content_pool.xml',
'encoach_exam_template/static/src/css/content_pool.css',
'encoach_exam_template/static/src/js/exam_validation.js',
'encoach_exam_template/static/src/xml/exam_validation.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,3 @@
from . import templates
from . import ielts_exam
from . import custom_exam

View File

@@ -0,0 +1,364 @@
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__)
def _section_to_dict(sec):
return {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'question_ids': sec.question_ids.ids,
}
def _exam_to_dict(exam):
return {
'id': exam.id,
'title': exam.title,
'template_id': exam.template_id.id if exam.template_id else None,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'description': exam.description or '',
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
'status': exam.status,
'sections': [_section_to_dict(s) for s in exam.section_ids],
}
class EncoachCustomExamController(http.Controller):
# ------------------------------------------------------------------
# POST /api/exam/custom/create
# ------------------------------------------------------------------
@http.route('/api/exam/custom/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
title = (body.get('title') or '').strip()
if not title:
return _error_response('title is required', 400)
vals = {
'title': title,
'teacher_id': request.env.user.id,
'subject_id': body.get('subject_id') or False,
'template_id': body.get('template_id') or False,
'entity_id': body.get('entity_id') or False,
'description': body.get('description', ''),
'total_time_min': body.get('total_time_min', 0),
'pass_threshold': body.get('pass_threshold', 0.0),
'results_release_mode': body.get('results_release_mode', 'auto'),
'randomize_questions': body.get('randomize_questions', False),
'status': 'draft',
}
exam = request.env['encoach.exam.custom'].sudo().create(vals)
sections = body.get('sections', [])
Section = request.env['encoach.exam.custom.section'].sudo()
for idx, sec_data in enumerate(sections):
sec_vals = {
'exam_id': exam.id,
'title': sec_data.get('title', f'Section {idx + 1}'),
'skill': sec_data.get('skill', ''),
'question_count': sec_data.get('question_count', 0),
'time_limit_min': sec_data.get('time_limit_min', 0),
'scoring_method': sec_data.get('scoring_method', 'auto'),
'sequence': sec_data.get('sequence', (idx + 1) * 10),
}
section = Section.create(sec_vals)
q_ids = sec_data.get('question_ids', [])
if q_ids:
section.write({'question_ids': [(6, 0, q_ids)]})
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam), 201)
except Exception as e:
_logger.exception('custom exam create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('custom exam get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be updated', 400)
body = _get_json_body()
allowed = {'title', 'description', 'total_time_min', 'pass_threshold',
'results_release_mode', 'randomize_questions', 'subject_id'}
vals = {k: v for k, v in body.items() if k in allowed}
if vals:
exam.write(vals)
sections_data = body.get('sections')
if sections_data and isinstance(sections_data, list):
Section = request.env['encoach.exam.custom.section'].sudo()
existing_ids = set(exam.section_ids.ids)
incoming_ids = set()
for idx, sec_data in enumerate(sections_data):
sec_id = sec_data.get('id')
sec_vals = {
'title': sec_data.get('title', f'Section {idx + 1}'),
'skill': sec_data.get('skill', ''),
'question_count': sec_data.get('question_count', 0),
'time_limit_min': sec_data.get('time_limit_min', 0),
'scoring_method': sec_data.get('scoring_method', 'auto'),
'sequence': sec_data.get('sequence', (idx + 1) * 10),
}
if sec_id and sec_id in existing_ids:
section = Section.browse(sec_id)
section.write(sec_vals)
incoming_ids.add(sec_id)
else:
sec_vals['exam_id'] = exam.id
section = Section.create(sec_vals)
incoming_ids.add(section.id)
q_ids = sec_data.get('question_ids')
if q_ids is not None:
section.write({'question_ids': [(6, 0, q_ids)]})
to_delete = existing_ids - incoming_ids
if to_delete:
Section.browse(list(to_delete)).unlink()
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('custom exam update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# DELETE /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be deleted', 400)
exam.section_ids.unlink()
exam.unlink()
return _json_response({'deleted': True}, 204)
except Exception as e:
_logger.exception('custom exam delete failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/custom/<int:exam_id>/save-template
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/save-template',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def save_template(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
structure = []
for sec in exam.section_ids.sorted('sequence'):
structure.append({
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
})
body = _get_json_body()
template_name = body.get('name', f'Template from: {exam.title}')
template = request.env['encoach.exam.template'].sudo().create({
'name': template_name,
'type': 'custom',
'teacher_id': request.env.user.id,
'subject_id': exam.subject_id.id if exam.subject_id else False,
'entity_id': exam.entity_id.id if exam.entity_id else False,
'structure': json.dumps(structure),
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
})
return _json_response({
'id': template.id,
'name': template.name,
'type': template.type,
'structure': structure,
}, 201)
except Exception as e:
_logger.exception('save_template failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/custom/<int:exam_id>/validate
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/validate',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def validate(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
errors = []
warnings = []
if not exam.section_ids:
errors.append('Exam has no sections')
for section in exam.section_ids:
actual = len(section.question_ids)
expected = section.question_count or 0
if actual == 0:
errors.append(
f'Section "{section.title}" has no questions assigned')
elif expected > 0 and actual < expected:
warnings.append(
f'Section "{section.title}" has {actual}/{expected} questions')
if not section.scoring_method:
warnings.append(
f'Section "{section.title}" has no scoring method set')
if not section.time_limit_min:
warnings.append(
f'Section "{section.title}" has no time limit set')
if not exam.total_time_min:
warnings.append('Exam has no total time limit set')
if not exam.pass_threshold:
warnings.append('Exam has no pass threshold set')
return _json_response({
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings,
})
except Exception as e:
_logger.exception('validate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/custom/<int:exam_id>/assign
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/assign',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def assign(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
student_ids = body.get('student_ids', [])
batch_id = body.get('batch_id')
access_start = body.get('access_start')
access_end = body.get('access_end')
Assignment = request.env['encoach.exam.assignment'].sudo()
created = 0
if student_ids:
for sid in student_ids:
Assignment.create({
'exam_id': exam.id,
'student_id': int(sid),
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
if batch_id:
try:
batch = request.env['op.batch'].sudo().browse(int(batch_id))
if batch.exists():
batch_students = request.env['op.student'].sudo().search(
[('batch_id', '=', batch.id)])
for student in batch_students:
user = student.user_id if hasattr(student, 'user_id') else None
if user:
Assignment.create({
'exam_id': exam.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
except Exception:
_logger.warning('Batch lookup failed for batch_id=%s', batch_id)
return _json_response({'assignments_created': created})
except Exception as e:
_logger.exception('assign failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,504 @@
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__)
IELTS_SKILLS = ['listening', 'reading', 'writing', 'speaking']
def _section_to_dict(sec):
return {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'question_ids': sec.question_ids.ids,
}
def _exam_to_dict(exam):
return {
'id': exam.id,
'title': exam.title,
'template_id': exam.template_id.id if exam.template_id else None,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'description': exam.description or '',
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
'status': exam.status,
'sections': [_section_to_dict(s) for s in exam.section_ids],
}
def _question_to_dict(q):
options = None
if q.options:
try:
options = json.loads(q.options)
except (json.JSONDecodeError, TypeError):
options = q.options
return {
'id': q.id,
'skill': q.skill,
'question_type': q.question_type,
'stem': q.stem,
'options': options,
'marks': q.marks,
'difficulty': q.difficulty,
'status': q.status,
'ielts_certified': q.ielts_certified,
}
class EncoachIeltsExamController(http.Controller):
# ------------------------------------------------------------------
# POST /api/exam/ielts/create
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
template_id = body.get('template_id')
title = (body.get('title') or '').strip()
entity_id = body.get('entity_id')
if not template_id or not title:
return _error_response('template_id and title are required', 400)
template = request.env['encoach.exam.template'].sudo().browse(
int(template_id))
if not template.exists():
return _error_response('Template not found', 404)
exam = request.env['encoach.exam.custom'].sudo().create({
'title': title,
'template_id': template.id,
'subject_id': template.subject_id.id if template.subject_id else False,
'entity_id': int(entity_id) if entity_id else False,
'teacher_id': request.env.user.id,
'total_time_min': template.total_time_min or 0,
'pass_threshold': template.pass_threshold or 0.0,
'results_release_mode': template.results_release_mode or 'auto',
'status': 'draft',
})
structure = None
if template.structure:
try:
structure = json.loads(template.structure)
except (json.JSONDecodeError, TypeError):
structure = None
Section = request.env['encoach.exam.custom.section'].sudo()
if structure and isinstance(structure, list):
for idx, sec_def in enumerate(structure):
Section.create({
'exam_id': exam.id,
'title': sec_def.get('title', f'Section {idx + 1}'),
'skill': sec_def.get('skill', ''),
'question_count': sec_def.get('question_count', 0),
'time_limit_min': sec_def.get('time_limit_min', 0),
'scoring_method': sec_def.get('scoring_method', 'auto'),
'sequence': sec_def.get('sequence', (idx + 1) * 10),
})
else:
for idx, skill in enumerate(IELTS_SKILLS):
Section.create({
'exam_id': exam.id,
'title': skill.capitalize(),
'skill': skill,
'question_count': 40 if skill in ('listening', 'reading') else 0,
'time_limit_min': {'listening': 30, 'reading': 60,
'writing': 60, 'speaking': 15}.get(skill, 30),
'scoring_method': 'auto' if skill in ('listening', 'reading') else 'rubric',
'sequence': (idx + 1) * 10,
})
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam), 201)
except Exception as e:
_logger.exception('ielts create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('ielts get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/ielts/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be updated', 400)
body = _get_json_body()
allowed = {'title', 'description', 'total_time_min', 'pass_threshold',
'results_release_mode', 'randomize_questions'}
vals = {k: v for k, v in body.items() if k in allowed}
if vals:
exam.write(vals)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('ielts update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/content-pool-count
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/content-pool-count',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def content_pool_count(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
Question = request.env['encoach.question'].sudo()
base_domain = [('status', '=', 'active')]
if exam.subject_id:
base_domain.append(('subject_id', '=', exam.subject_id.id))
counts = {}
for skill in IELTS_SKILLS:
domain = base_domain + [('skill', '=', skill)]
counts[skill] = Question.search_count(domain)
return _json_response(counts)
except Exception as e:
_logger.exception('content_pool_count failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/content-pool
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/content-pool',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def content_pool(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
domain = [('status', '=', 'active')]
if exam.subject_id:
domain.append(('subject_id', '=', exam.subject_id.id))
skill = kw.get('skill')
if skill:
domain.append(('skill', '=', skill))
difficulty = kw.get('difficulty')
if difficulty:
domain.append(('difficulty', '=', difficulty))
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Question = request.env['encoach.question']
records, total = _paginate(Question, domain, page=page, size=size)
return _json_response({
'items': [_question_to_dict(q) for q in records],
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('content_pool failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/auto-assemble
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/auto-assemble',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def auto_assemble(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be auto-assembled', 400)
Question = request.env['encoach.question'].sudo()
base_domain = [('status', '=', 'active')]
if exam.subject_id:
base_domain.append(('subject_id', '=', exam.subject_id.id))
assembled = []
for section in exam.section_ids:
needed = section.question_count or 10
domain = base_domain + [('skill', '=', section.skill)] if section.skill else base_domain
candidates = Question.search(domain, order='difficulty asc, id asc')
easy = candidates.filtered(lambda q: q.difficulty == 'easy')
medium = candidates.filtered(lambda q: q.difficulty == 'medium')
hard = candidates.filtered(lambda q: q.difficulty == 'hard')
easy_count = max(1, needed // 3)
hard_count = max(1, needed // 3)
medium_count = needed - easy_count - hard_count
selected_ids = []
selected_ids.extend(easy[:easy_count].ids)
selected_ids.extend(medium[:medium_count].ids)
selected_ids.extend(hard[:hard_count].ids)
if len(selected_ids) < needed:
remaining = candidates.filtered(lambda q: q.id not in selected_ids)
selected_ids.extend(remaining[:needed - len(selected_ids)].ids)
section.write({'question_ids': [(6, 0, selected_ids)]})
assembled.append({
'section_id': section.id,
'title': section.title,
'skill': section.skill or '',
'questions_assigned': len(selected_ids),
'question_ids': selected_ids,
})
return _json_response({'sections': assembled})
except Exception as e:
_logger.exception('auto_assemble failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/suggest
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/suggest',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def suggest(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
section_id = body.get('section_id')
criteria = body.get('criteria', {})
section = request.env['encoach.exam.custom.section'].sudo().browse(
int(section_id)) if section_id else None
if section_id and (not section or not section.exists()):
return _error_response('Section not found', 404)
domain = [('status', '=', 'active')]
if exam.subject_id:
domain.append(('subject_id', '=', exam.subject_id.id))
if section and section.skill:
domain.append(('skill', '=', section.skill))
if criteria.get('difficulty'):
domain.append(('difficulty', '=', criteria['difficulty']))
if criteria.get('question_type'):
domain.append(('question_type', '=', criteria['question_type']))
if section:
current_ids = section.question_ids.ids
if current_ids:
domain.append(('id', 'not in', current_ids))
suggestions = request.env['encoach.question'].sudo().search(
domain, limit=int(criteria.get('limit', 10)))
return _json_response({
'suggestions': [_question_to_dict(q) for q in suggestions],
})
except Exception as e:
_logger.exception('suggest failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/ielts/<int:exam_id>/sections/<int:section_id>/questions
# ------------------------------------------------------------------
@http.route(
'/api/exam/ielts/<int:exam_id>/sections/<int:section_id>/questions',
type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_section_questions(self, exam_id, section_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
section = request.env['encoach.exam.custom.section'].sudo().browse(
section_id)
if not section.exists() or section.exam_id.id != exam.id:
return _error_response('Section not found in this exam', 404)
body = _get_json_body()
question_ids = body.get('question_ids', [])
if not isinstance(question_ids, list):
return _error_response('question_ids must be a list', 400)
valid = request.env['encoach.question'].sudo().browse(question_ids)
valid_ids = valid.filtered(lambda q: q.exists()).ids
section.write({'question_ids': [(6, 0, valid_ids)]})
return _json_response(_section_to_dict(section))
except Exception as e:
_logger.exception('update_section_questions failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/validate
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/validate',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def validate(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
errors = []
warnings = []
if not exam.section_ids:
errors.append('Exam has no sections')
for section in exam.section_ids:
actual = len(section.question_ids)
expected = section.question_count or 0
if actual == 0:
errors.append(
f'Section "{section.title}" has no questions assigned')
elif expected > 0 and actual < expected:
warnings.append(
f'Section "{section.title}" has {actual}/{expected} questions')
if not section.time_limit_min:
warnings.append(
f'Section "{section.title}" has no time limit set')
if not exam.total_time_min:
warnings.append('Exam has no total time limit set')
has_all_skills = {s.skill for s in exam.section_ids if s.skill}
for skill in IELTS_SKILLS:
if skill not in has_all_skills:
warnings.append(f'Missing IELTS section: {skill}')
return _json_response({
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings,
})
except Exception as e:
_logger.exception('validate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/assign
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/assign',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def assign(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
student_ids = body.get('student_ids', [])
batch_id = body.get('batch_id')
access_start = body.get('access_start')
access_end = body.get('access_end')
Assignment = request.env['encoach.exam.assignment'].sudo()
created = 0
if student_ids:
for sid in student_ids:
Assignment.create({
'exam_id': exam.id,
'student_id': int(sid),
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
if batch_id:
try:
batch = request.env['op.batch'].sudo().browse(int(batch_id))
if batch.exists():
batch_students = request.env['op.student'].sudo().search(
[('batch_id', '=', batch.id)])
for student in batch_students:
user = student.user_id if hasattr(student, 'user_id') else None
if user:
Assignment.create({
'exam_id': exam.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
except Exception:
_logger.warning('Batch lookup failed for batch_id=%s', batch_id)
return _json_response({'assignments_created': created})
except Exception as e:
_logger.exception('assign failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,130 @@
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__)
def _template_to_dict(rec):
structure = None
if rec.structure:
try:
structure = json.loads(rec.structure)
except (json.JSONDecodeError, TypeError):
structure = rec.structure
return {
'id': rec.id,
'name': rec.name,
'code': rec.code or '',
'type': rec.type,
'editable': rec.editable,
'active': rec.active,
'subject_id': rec.subject_id.id if rec.subject_id else None,
'subject_name': rec.subject_id.name if rec.subject_id else None,
'entity_id': rec.entity_id.id if rec.entity_id else None,
'teacher_id': rec.teacher_id.id if rec.teacher_id else None,
'structure': structure,
'total_time_min': rec.total_time_min or 0,
'pass_threshold': rec.pass_threshold or 0.0,
'results_release_mode': rec.results_release_mode or 'auto',
'randomize_questions': rec.randomize_questions,
'description': rec.description or '',
}
class EncoachTemplatesController(http.Controller):
# ------------------------------------------------------------------
# GET /api/exam/templates
# ------------------------------------------------------------------
@http.route('/api/exam/templates', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_templates(self, **kw):
try:
domain = [('active', '=', True)]
tpl_type = kw.get('type')
if tpl_type in ('international', 'custom'):
domain.append(('type', '=', tpl_type))
subject_id = kw.get('subject_id')
if subject_id:
domain.append(('subject_id', '=', int(subject_id)))
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Template = request.env['encoach.exam.template']
records, total = _paginate(Template, domain, page=page, size=size)
return _json_response({
'items': [_template_to_dict(r) for r in records],
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('list_templates failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/templates/<int:template_id>
# ------------------------------------------------------------------
@http.route('/api/exam/templates/<int:template_id>', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_template(self, template_id, **kw):
try:
rec = request.env['encoach.exam.template'].sudo().browse(template_id)
if not rec.exists():
return _error_response('Template not found', 404)
return _json_response(_template_to_dict(rec))
except Exception as e:
_logger.exception('get_template failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/templates/custom
# ------------------------------------------------------------------
@http.route('/api/exam/templates/custom', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_custom_template(self, **kw):
try:
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('name is required', 400)
structure = body.get('structure')
if structure and not isinstance(structure, str):
structure = json.dumps(structure)
vals = {
'name': name,
'type': 'custom',
'teacher_id': request.env.user.id,
'subject_id': body.get('subject_id'),
'structure': structure,
'total_time_min': body.get('total_time_min', 0),
'pass_threshold': body.get('pass_threshold', 0.0),
'results_release_mode': body.get('results_release_mode', 'auto'),
'randomize_questions': body.get('randomize_questions', False),
'description': body.get('description', ''),
}
rec = request.env['encoach.exam.template'].sudo().create(vals)
return _json_response(_template_to_dict(rec), 201)
except Exception as e:
_logger.exception('create_custom_template failed')
return _error_response(str(e), 500)

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