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

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)

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- IELTS Academic Template -->
<!-- ============================================================ -->
<record id="template_ielts_academic" model="encoach.exam.template">
<field name="name">IELTS Academic</field>
<field name="code">ielts_academic</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">170</field>
<field name="results_release_mode">manual_approval</field>
<field name="description">The International English Language Testing System (IELTS) Academic test measures English language proficiency for academic purposes. It is recognized by over 11,000 organizations worldwide including universities, employers, immigration authorities, and professional bodies. The test assesses all four language skills: listening, reading, writing, and speaking.</field>
<field name="structure">{
"sections": [
{"skill": "listening", "parts": 4, "questions": 40, "time_min": 30},
{"skill": "reading", "parts": 3, "questions": 40, "time_min": 60},
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 60},
{"skill": "speaking", "parts": 3, "questions": 0, "time_min": 15}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- IELTS General Training Template -->
<!-- ============================================================ -->
<record id="template_ielts_general" model="encoach.exam.template">
<field name="name">IELTS General Training</field>
<field name="code">ielts_general</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">170</field>
<field name="results_release_mode">manual_approval</field>
<field name="description">The IELTS General Training test measures English language proficiency in a practical, everyday context. It is suitable for those planning to undertake non-academic training, gain work experience, or immigrate to an English-speaking country. The reading section draws from notices, advertisements, company handbooks, and newspapers.</field>
<field name="structure">{
"sections": [
{"skill": "listening", "parts": 4, "questions": 40, "time_min": 30},
{"skill": "reading", "parts": 5, "questions": 40, "time_min": 60},
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 60},
{"skill": "speaking", "parts": 3, "questions": 0, "time_min": 15}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- TOEFL iBT Template -->
<!-- ============================================================ -->
<record id="template_toefl" model="encoach.exam.template">
<field name="name">TOEFL iBT</field>
<field name="code">toefl</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">200</field>
<field name="results_release_mode">manual_approval</field>
<field name="description">The Test of English as a Foreign Language Internet-Based Test (TOEFL iBT) measures the ability to use and understand English at the university level. It evaluates how well reading, listening, speaking, and writing skills are combined to perform academic tasks. Scores are accepted by more than 12,500 institutions in over 160 countries.</field>
<field name="structure">{
"sections": [
{"skill": "reading", "parts": 3, "questions": 30, "time_min": 54},
{"skill": "listening", "parts": 3, "questions": 28, "time_min": 41},
{"skill": "speaking", "parts": 4, "questions": 4, "time_min": 17},
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 50}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- STEP Template -->
<!-- ============================================================ -->
<record id="template_step" model="encoach.exam.template">
<field name="name">STEP (Standardized Test of English Proficiency)</field>
<field name="code">step</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">150</field>
<field name="pass_threshold">60.0</field>
<field name="results_release_mode">auto</field>
<field name="description">The Standardized Test of English Proficiency (STEP) is administered by the Saudi National Center for Assessment. It measures English language competency for Saudi students and professionals across listening comprehension, reading comprehension, grammar, and vocabulary. Results are used for university admissions, employment, and scholarship applications within Saudi Arabia.</field>
<field name="structure">{
"sections": [
{"skill": "listening", "parts": 2, "questions": 20, "time_min": 25},
{"skill": "reading", "parts": 4, "questions": 40, "time_min": 65},
{"skill": "grammar", "parts": 2, "questions": 30, "time_min": 30},
{"skill": "vocabulary", "parts": 1, "questions": 10, "time_min": 15}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- IC3 Template -->
<!-- ============================================================ -->
<record id="template_ic3" model="encoach.exam.template">
<field name="name">IC3 (Internet and Computing Core Certification)</field>
<field name="code">ic3</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">150</field>
<field name="pass_threshold">70.0</field>
<field name="results_release_mode">auto</field>
<field name="description">The Internet and Computing Core Certification (IC3) is a global benchmark for basic computer literacy. It validates foundational knowledge across computing fundamentals, key software applications, and internet and networking concepts required for academic and professional environments. The certification is administered by Certiport and recognized internationally.</field>
<field name="structure">{
"sections": [
{"skill": "computing_fundamentals", "parts": 1, "questions": 45, "time_min": 50},
{"skill": "key_applications", "parts": 1, "questions": 45, "time_min": 50},
{"skill": "living_online", "parts": 1, "questions": 45, "time_min": 50}
]
}</field>
</record>
</data>
</odoo>

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