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:
156
new_project/Developer Workflow Manual.txt
Normal file
156
new_project/Developer Workflow Manual.txt
Normal file
@@ -0,0 +1,156 @@
|
||||
|
||||
|
||||
|
||||
# Developer Workflow Manual — EnCoach Backend & Frontend
|
||||
|
||||
## One-Time Setup (Do This Once)
|
||||
|
||||
**Step 1 — Clone the repositories**
|
||||
```bash
|
||||
git clone https://git.albousalh.com/devops/encoach_backend_new_v2.git
|
||||
git clone https://git.albousalh.com/devops/encoach_frontend_new_v2.git
|
||||
```
|
||||
|
||||
**Step 2 — Switch to the working branch**
|
||||
```bash
|
||||
cd encoach_backend_new_v2
|
||||
git checkout full_stack_dev
|
||||
|
||||
cd ../encoach_frontend_new_v2
|
||||
git checkout full_stack_dev
|
||||
```
|
||||
|
||||
**Step 3 — Install the `tea` CLI (to open PRs from terminal)**
|
||||
```bash
|
||||
# macOS
|
||||
brew install tea
|
||||
|
||||
# Or download directly:
|
||||
# https://gitea.com/gitea/tea/releases
|
||||
|
||||
# Configure it (run once):
|
||||
tea login add --name encoach --url https://git.albousalh.com --token YOUR_TOKEN
|
||||
```
|
||||
> To get your token: log in at `https://git.albousalh.com` → top-right avatar → Settings → Applications → Generate Token.
|
||||
|
||||
---
|
||||
|
||||
## Every Day — Starting Work
|
||||
|
||||
**Step 4 — Always pull the latest before coding**
|
||||
```bash
|
||||
git checkout full_stack_dev
|
||||
git pull origin full_stack_dev
|
||||
```
|
||||
> This makes sure you have the latest code before making changes.
|
||||
|
||||
---
|
||||
|
||||
## Making Changes
|
||||
|
||||
**Step 5 — Make your code changes locally**
|
||||
|
||||
Work as normal in your editor. No restrictions on what you change.
|
||||
|
||||
**Step 6 — If you add a new Python package**, add it to `new_project/requirements.txt`:
|
||||
```
|
||||
openai>=1.50
|
||||
your-new-package>=x.x
|
||||
```
|
||||
|
||||
**Step 7 — If you add a new Odoo addon**, place it under `new_project/custom_addons/your_module/` and make sure:
|
||||
- The `__manifest__.py` lists **all** modules you reference via `comodel_name=` in its `depends` list.
|
||||
- Notify Talal — new addons need a one-time manual install on the server (see end of this doc).
|
||||
|
||||
---
|
||||
|
||||
## Pushing Your Work
|
||||
|
||||
**Step 8 — Stage and commit your changes**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Short description of what you changed"
|
||||
```
|
||||
|
||||
**Step 9 — Push to your working branch**
|
||||
```bash
|
||||
git push origin full_stack_dev
|
||||
```
|
||||
|
||||
**Step 10 — Open a Pull Request** (asks Talal for review/approval)
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo devops/encoach_backend_new_v2 \
|
||||
--head full_stack_dev \
|
||||
--base main \
|
||||
--title "Brief title of your changes" \
|
||||
--description "What you changed and why"
|
||||
```
|
||||
> For frontend, replace `encoach_backend_new_v2` with `encoach_frontend_new_v2`.
|
||||
|
||||
**Step 11 — Notify Talal** that a PR is waiting for his review.
|
||||
|
||||
---
|
||||
|
||||
## After Talal Approves & Merges
|
||||
|
||||
**Step 12 — Deployment is fully automatic.** You do not need to do anything on the server.
|
||||
|
||||
What happens behind the scenes:
|
||||
1. Talal merges the PR on `https://git.albousalh.com`
|
||||
2. The server detects the merge to `main`
|
||||
3. The deploy script pulls the latest code
|
||||
4. Docker rebuilds the image (if `Dockerfile` or `requirements.txt` changed — takes ~10–15 min; otherwise ~1 min)
|
||||
5. The container restarts with the new code
|
||||
|
||||
**Step 13 — Verify it worked** by checking the live URL:
|
||||
- Frontend: `http://5.189.151.117:3000`
|
||||
- Backend API: `http://5.189.151.117:8069/api/login`
|
||||
|
||||
---
|
||||
|
||||
## Checking Logs If Something Goes Wrong
|
||||
|
||||
SSH into the server (ask Talal for credentials) and run:
|
||||
```bash
|
||||
# Backend logs
|
||||
docker logs backend-v2-odoo --tail 50
|
||||
|
||||
# Frontend logs
|
||||
docker logs encoach-frontend --tail 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Case: Adding a New Odoo Addon
|
||||
|
||||
New addons need a **one-time manual install** on the server (the database doesn't know about them yet). Tell Talal and he will run:
|
||||
```bash
|
||||
ssh root@5.189.151.117
|
||||
docker stop backend-v2-odoo
|
||||
docker run --rm \
|
||||
--network backend-v2_default \
|
||||
-v /opt/encoach/backend-v2/odoo-docker.conf:/etc/odoo/odoo.conf:ro \
|
||||
-v /opt/encoach/backend-v2/new_project/custom_addons:/mnt/custom_addons:ro \
|
||||
-v /opt/encoach/backend-v2/new_project/enterprise-19:/mnt/enterprise:ro \
|
||||
-v /opt/encoach/backend-v2/new_project/openeducat_erp-19.0:/mnt/openeducat:ro \
|
||||
-v odoo-web-data:/var/lib/odoo \
|
||||
encoach-backend:latest \
|
||||
odoo -c /etc/odoo/odoo.conf -d encoach_v2 -i your_new_module --stop-after-init
|
||||
docker start backend-v2-odoo
|
||||
```
|
||||
> After the first install, all future deploys update it automatically.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Action | Command |
|
||||
|---|---|
|
||||
| Pull latest | `git pull origin full_stack_dev` |
|
||||
| Commit | `git add . && git commit -m "message"` |
|
||||
| Push | `git push origin full_stack_dev` |
|
||||
| Open PR (backend) | `tea pr create --repo devops/encoach_backend_new_v2 --head full_stack_dev --base main --title "..."` |
|
||||
| Open PR (frontend) | `tea pr create --repo devops/encoach_frontend_new_v2 --head full_stack_dev --base main --title "..."` |
|
||||
| View logs (backend) | `docker logs backend-v2-odoo --tail 50` |
|
||||
| View logs (frontend) | `docker logs encoach-frontend --tail 50` |
|
||||
2479
new_project/ENCOACH_WORKFLOWS_BACKEND_SRS.md
Normal file
2479
new_project/ENCOACH_WORKFLOWS_BACKEND_SRS.md
Normal file
File diff suppressed because it is too large
Load Diff
2109
new_project/ENCOACH_WORKFLOWS_FRONTEND_SRS.md
Normal file
2109
new_project/ENCOACH_WORKFLOWS_FRONTEND_SRS.md
Normal file
File diff suppressed because it is too large
Load Diff
3
new_project/custom_addons/encoach_adaptive/__init__.py
Normal file
3
new_project/custom_addons/encoach_adaptive/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
25
new_project/custom_addons/encoach_adaptive/__manifest__.py
Normal file
25
new_project/custom_addons/encoach_adaptive/__manifest__.py
Normal 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',
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import adaptive
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAdaptiveEvent(models.Model):
|
||||
_name = 'encoach.adaptive.event'
|
||||
_description = 'Adaptive Learning Event'
|
||||
_order = 'created_at desc'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null')
|
||||
event_type = fields.Selection([
|
||||
('signal', 'Signal'),
|
||||
('decision', 'Decision'),
|
||||
], required=True)
|
||||
signal_name = fields.Char(size=100)
|
||||
signal_value = fields.Float()
|
||||
decision = fields.Char(size=200)
|
||||
context = fields.Text()
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
@@ -0,0 +1,16 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAdaptivePath(models.Model):
|
||||
_name = 'encoach.adaptive.path'
|
||||
_description = 'Adaptive Learning Path'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null')
|
||||
module_queue = fields.Text()
|
||||
source = fields.Selection([
|
||||
('placement', 'Placement'),
|
||||
('exam', 'Exam'),
|
||||
('ai_generated', 'AI Generated'),
|
||||
])
|
||||
next_generation_brief = fields.Text()
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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
|
||||
|
@@ -0,0 +1 @@
|
||||
from .adaptive_engine import AdaptiveEngine
|
||||
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
import json
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdaptiveEngine:
|
||||
"""4-phase adaptive learning engine.
|
||||
|
||||
Phase 1: Module-level up/down stepping
|
||||
Phase 2: Micro-lesson injection
|
||||
Phase 3: Module skipping
|
||||
Phase 4: No-progress alerts
|
||||
"""
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
'step_up_threshold': 0.85,
|
||||
'step_down_threshold': 0.50,
|
||||
'micro_lesson_trigger': 2,
|
||||
'module_skip_threshold': 0.95,
|
||||
'no_progress_alert_days': 3,
|
||||
'max_retries': 3,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_settings(env, teacher_id=None, entity_id=None):
|
||||
"""Get adaptive settings for teacher/entity or defaults."""
|
||||
Settings = env['encoach.adaptive.settings'].sudo()
|
||||
settings = None
|
||||
if teacher_id:
|
||||
settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1)
|
||||
if not settings and entity_id:
|
||||
settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1)
|
||||
|
||||
if settings:
|
||||
return {
|
||||
'step_up_threshold': settings.step_up_threshold,
|
||||
'step_down_threshold': settings.step_down_threshold,
|
||||
'micro_lesson_trigger': settings.micro_lesson_trigger,
|
||||
'module_skip_threshold': settings.module_skip_threshold,
|
||||
'no_progress_alert_days': settings.no_progress_alert_days,
|
||||
'max_retries': settings.max_retries,
|
||||
}
|
||||
return dict(AdaptiveEngine.DEFAULT_SETTINGS)
|
||||
|
||||
@staticmethod
|
||||
def process_checkpoint(env, student_id, course_id, module_id, score, settings=None):
|
||||
"""Process a module checkpoint and make adaptive decisions."""
|
||||
if not settings:
|
||||
settings = AdaptiveEngine.DEFAULT_SETTINGS
|
||||
|
||||
Event = env['encoach.adaptive.event'].sudo()
|
||||
Module = env['encoach.course.module'].sudo()
|
||||
module = Module.browse(module_id)
|
||||
|
||||
decision = None
|
||||
signals = []
|
||||
|
||||
signals.append({
|
||||
'signal_name': 'checkpoint_score',
|
||||
'signal_value': score,
|
||||
})
|
||||
|
||||
if score >= settings['step_up_threshold']:
|
||||
decision = 'step_up'
|
||||
module.write({'status': 'completed'})
|
||||
next_module = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('sequence', '>', module.sequence),
|
||||
('status', '=', 'locked'),
|
||||
], limit=1, order='sequence')
|
||||
if next_module:
|
||||
next_module.write({'status': 'available'})
|
||||
|
||||
elif score < settings['step_down_threshold']:
|
||||
decision = 'step_down'
|
||||
|
||||
else:
|
||||
decision = 'continue'
|
||||
module.write({'status': 'completed'})
|
||||
next_module = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('sequence', '>', module.sequence),
|
||||
('status', '=', 'locked'),
|
||||
], limit=1, order='sequence')
|
||||
if next_module:
|
||||
next_module.write({'status': 'available'})
|
||||
|
||||
if score >= settings['module_skip_threshold']:
|
||||
skip_modules = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('sequence', '>', module.sequence),
|
||||
('status', '=', 'locked'),
|
||||
], limit=2, order='sequence')
|
||||
for sm in skip_modules:
|
||||
sm.write({'status': 'skipped'})
|
||||
if skip_modules:
|
||||
decision = 'skip_ahead'
|
||||
signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)})
|
||||
|
||||
for sig in signals:
|
||||
Event.create({
|
||||
'student_id': student_id,
|
||||
'course_id': course_id,
|
||||
'event_type': 'signal',
|
||||
'signal_name': sig['signal_name'],
|
||||
'signal_value': sig['signal_value'],
|
||||
})
|
||||
|
||||
Event.create({
|
||||
'student_id': student_id,
|
||||
'course_id': course_id,
|
||||
'event_type': 'decision',
|
||||
'decision': decision,
|
||||
'context': json.dumps({'module_id': module_id, 'score': score}),
|
||||
})
|
||||
|
||||
return {
|
||||
'decision': decision,
|
||||
'score': score,
|
||||
'signals': signals,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def check_no_progress(env, student_id, course_id, settings=None):
|
||||
"""Phase 4: Check if student has stalled."""
|
||||
if not settings:
|
||||
settings = AdaptiveEngine.DEFAULT_SETTINGS
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days'])
|
||||
|
||||
Event = env['encoach.adaptive.event'].sudo()
|
||||
recent_events = Event.search_count([
|
||||
('student_id', '=', student_id),
|
||||
('course_id', '=', course_id),
|
||||
('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')),
|
||||
])
|
||||
|
||||
if recent_events == 0:
|
||||
Event.create({
|
||||
'student_id': student_id,
|
||||
'course_id': course_id,
|
||||
'event_type': 'signal',
|
||||
'signal_name': 'no_progress_alert',
|
||||
'signal_value': settings['no_progress_alert_days'],
|
||||
})
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,54 @@
|
||||
.o_signal_timeline .st-timeline {
|
||||
position: relative;
|
||||
padding-left: 48px;
|
||||
}
|
||||
.o_signal_timeline .st-timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 19px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #dee2e6;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-marker {
|
||||
position: absolute;
|
||||
left: -48px;
|
||||
top: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
z-index: 1;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.o_signal_timeline .st-timeline-content {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-content .card {
|
||||
border-radius: 0.5rem;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-content .card:hover {
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
.o_signal_timeline .st-student-card {
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_signal_timeline .st-student-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.o_signal_timeline .cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
@@ -0,0 +1,177 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_adaptive.SignalTimeline">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_signal_timeline">
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||
<p class="mt-2 text-muted">Loading timeline...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Student Selector -->
|
||||
<t t-elif="!state.studentId and state.studentList">
|
||||
<h2 class="mb-3">Adaptive Signal Timeline</h2>
|
||||
<p class="text-muted mb-4">Select a student to view their adaptive learning events.</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
|
||||
<div class="col-lg-3 col-md-4 col-sm-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer st-student-card"
|
||||
t-on-click="() => this.selectStudent(st.id)">
|
||||
<div class="card-body text-center">
|
||||
<div class="rounded-circle bg-info bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
|
||||
<i class="fa fa-user text-info"/>
|
||||
</div>
|
||||
<h6 class="card-title mb-0" t-esc="st.name"/>
|
||||
<small class="text-muted" t-esc="st.email"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Timeline View -->
|
||||
<t t-elif="state.student">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
|
||||
t-if="!this.props.action?.context?.student_id">
|
||||
<i class="fa fa-arrow-left me-1"/> Back
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="mb-0">Adaptive Timeline</h2>
|
||||
<small class="text-muted">
|
||||
Student: <strong t-esc="state.student.name"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Event Type</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('eventType', ev)">
|
||||
<option value="">All Events</option>
|
||||
<option value="signal">Signals</option>
|
||||
<option value="decision">Decisions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">From Date</label>
|
||||
<input type="date" class="form-control form-control-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('dateFrom', ev)"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">To Date</label>
|
||||
<input type="date" class="form-control form-control-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('dateTo', ev)"/>
|
||||
</div>
|
||||
<div class="col-md-3 text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="clearFilters">
|
||||
<i class="fa fa-refresh me-1"/> Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Badges -->
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<div class="bg-light rounded px-3 py-2 d-flex align-items-center">
|
||||
<i class="fa fa-list me-2 text-secondary"/>
|
||||
<strong class="me-1" t-esc="state.totalCount"/> events
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
|
||||
<i class="fa fa-bolt me-2 text-primary"/>
|
||||
<strong class="me-1" t-esc="signalCount"/> signals
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
|
||||
<i class="fa fa-gavel me-2 text-success"/>
|
||||
<strong class="me-1" t-esc="decisionCount"/> decisions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="st-timeline">
|
||||
<t t-foreach="state.filteredEvents" t-as="ev" t-key="ev.id">
|
||||
<div class="st-timeline-item">
|
||||
<div class="st-timeline-marker"
|
||||
t-att-class="'bg-' + eventColor(ev.event_type)">
|
||||
<i class="fa" t-att-class="eventIcon(ev.event_type)"/>
|
||||
</div>
|
||||
<div class="st-timeline-content">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<div>
|
||||
<span class="badge me-1"
|
||||
t-att-class="'bg-' + eventColor(ev.event_type)"
|
||||
t-esc="ev.event_type"/>
|
||||
<strong t-if="ev.signal_name" t-esc="ev.signal_name"/>
|
||||
<strong t-elif="ev.decision">Decision</strong>
|
||||
</div>
|
||||
<small class="text-muted" t-esc="formatDate(ev.created_at)"/>
|
||||
</div>
|
||||
|
||||
<!-- Signal details -->
|
||||
<div t-if="ev.event_type === 'signal'" class="mt-1">
|
||||
<span class="text-muted small">Value: </span>
|
||||
<span class="fw-bold" t-esc="ev.signal_value"/>
|
||||
<t t-if="ev.course_id">
|
||||
<span class="text-muted small ms-3">Course: </span>
|
||||
<span t-esc="ev.course_id[1]"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Decision details -->
|
||||
<div t-if="ev.event_type === 'decision'" class="mt-1">
|
||||
<span class="small" t-esc="ev.decision"/>
|
||||
<t t-if="ev.course_id">
|
||||
<span class="text-muted small ms-3">Course: </span>
|
||||
<span t-esc="ev.course_id[1]"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Context (if any) -->
|
||||
<div t-if="parseContext(ev.context)" class="mt-1">
|
||||
<small class="text-muted">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<t t-foreach="Object.entries(parseContext(ev.context) || {})" t-as="entry" t-key="entry[0]">
|
||||
<span class="me-2">
|
||||
<span class="fw-bold" t-esc="entry[0]"/>: <t t-esc="entry[1]"/>
|
||||
</span>
|
||||
</t>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="!state.filteredEvents.length">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-clock-o fa-3x mb-2 d-block"/>
|
||||
<p>No adaptive events found for this student.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-elif="!state.studentId">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-clock-o fa-3x mb-3 d-block"/>
|
||||
<p>No student selected. Open from a student record or select one above.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_adaptive_events"
|
||||
name="Adaptive Events"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="encoach_adaptive.action_adaptive_event"
|
||||
sequence="60"/>
|
||||
|
||||
<menuitem id="menu_adaptive_paths"
|
||||
name="Adaptive Paths"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="encoach_adaptive.action_adaptive_path"
|
||||
sequence="70"/>
|
||||
|
||||
<menuitem id="menu_adaptive_settings"
|
||||
name="Adaptive Settings"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="encoach_adaptive.action_adaptive_settings"
|
||||
sequence="80"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_adaptive_path_form" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.path.form</field>
|
||||
<field name="model">encoach.adaptive.path</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adaptive Path">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="source"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="module_queue"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="next_generation_brief"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_adaptive_path_list" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.path.list</field>
|
||||
<field name="model">encoach.adaptive.path</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Adaptive Paths">
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="source"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_adaptive_path" model="ir.actions.act_window">
|
||||
<field name="name">Adaptive Paths</field>
|
||||
<field name="res_model">encoach.adaptive.path</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_adaptive_settings_form" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.settings.form</field>
|
||||
<field name="model">encoach.adaptive.settings</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adaptive Settings">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="teacher_id"/>
|
||||
<field name="entity_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="step_up_threshold"/>
|
||||
<field name="step_down_threshold"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="micro_lesson_trigger"/>
|
||||
<field name="module_skip_threshold"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="no_progress_alert_days"/>
|
||||
<field name="max_retries"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_adaptive_settings_list" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.settings.list</field>
|
||||
<field name="model">encoach.adaptive.settings</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Adaptive Settings">
|
||||
<field name="teacher_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="step_up_threshold"/>
|
||||
<field name="step_down_threshold"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_adaptive_settings" model="ir.actions.act_window">
|
||||
<field name="name">Adaptive Settings</field>
|
||||
<field name="res_model">encoach.adaptive.settings</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_signal_timeline" model="ir.actions.client">
|
||||
<field name="name">Signal Timeline</field>
|
||||
<field name="tag">encoach_signal_timeline</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_signal_timeline"
|
||||
name="Signal Timeline"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="action_signal_timeline"
|
||||
sequence="55"/>
|
||||
|
||||
</odoo>
|
||||
3
new_project/custom_addons/encoach_ai_course/__init__.py
Normal file
3
new_project/custom_addons/encoach_ai_course/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
18
new_project/custom_addons/encoach_ai_course/__manifest__.py
Normal file
18
new_project/custom_addons/encoach_ai_course/__manifest__.py
Normal 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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import ai_course
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import ai_generation_log
|
||||
from . import ai_ielts_generation_log
|
||||
@@ -0,0 +1,25 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAiGenerationLog(models.Model):
|
||||
_name = 'encoach.ai.generation.log'
|
||||
_description = 'AI Generation Log'
|
||||
|
||||
student_id = fields.Many2one('res.users', ondelete='set null')
|
||||
course_type = fields.Selection([
|
||||
('general_english', 'General English'),
|
||||
('ielts', 'IELTS'),
|
||||
], required=True)
|
||||
brief = fields.Text()
|
||||
attempts = fields.Integer(default=0)
|
||||
final_resource_id = fields.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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
|
||||
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,2 @@
|
||||
from .english_pipeline import EnglishPipeline
|
||||
from .ielts_pipeline import IeltsPipeline
|
||||
@@ -0,0 +1,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}
|
||||
@@ -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', []),
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_ai_generation_logs"
|
||||
name="AI Generation Logs"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_ai_course.action_ai_generation_log"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_ai_ielts_logs"
|
||||
name="IELTS Generation Logs"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_ai_course.action_ai_ielts_log"
|
||||
sequence="40"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ai_generation_log_form" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.generation.log.form</field>
|
||||
<field name="model">encoach.ai.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Generation Log">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="generating,quality_check,pending_review,approved"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="course_type"/>
|
||||
<field name="attempts"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="final_resource_id"/>
|
||||
<field name="approved_by"/>
|
||||
<field name="created_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Brief" name="brief">
|
||||
<field name="brief"/>
|
||||
</page>
|
||||
<page string="Error Log" name="error_log">
|
||||
<field name="error_log"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_generation_log_list" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.generation.log.list</field>
|
||||
<field name="model">encoach.ai.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Generation Logs">
|
||||
<field name="course_type"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'pending_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
|
||||
<field name="attempts"/>
|
||||
<field name="student_id"/>
|
||||
<field name="created_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_generation_log_search" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.generation.log.search</field>
|
||||
<field name="model">encoach.ai.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Generation Logs">
|
||||
<field name="student_id"/>
|
||||
<separator/>
|
||||
<filter string="Generating" name="generating" domain="[('status', '=', 'generating')]"/>
|
||||
<filter string="Quality Check" name="quality_check" domain="[('status', '=', 'quality_check')]"/>
|
||||
<filter string="Pending Review" name="pending_review" domain="[('status', '=', 'pending_review')]"/>
|
||||
<filter string="Approved" name="approved" domain="[('status', '=', 'approved')]"/>
|
||||
<filter string="Rejected" name="rejected" domain="[('status', '=', 'rejected')]"/>
|
||||
<separator/>
|
||||
<filter string="General English" name="general_english" domain="[('course_type', '=', 'general_english')]"/>
|
||||
<filter string="IELTS" name="ielts" domain="[('course_type', '=', 'ielts')]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_generation_log" model="ir.actions.act_window">
|
||||
<field name="name">AI Generation Logs</field>
|
||||
<field name="res_model">encoach.ai.generation.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ai_ielts_log_form" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.form</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="IELTS Generation Log">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="generating,format_check,examiner_review,approved"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="skill"/>
|
||||
<field name="attempts"/>
|
||||
<field name="examiner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="created_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Brief" name="brief">
|
||||
<field name="brief"/>
|
||||
</page>
|
||||
<page string="Format Check Result" name="format_check">
|
||||
<field name="format_check_result"/>
|
||||
</page>
|
||||
<page string="Band Check Result" name="band_check">
|
||||
<field name="band_check_result"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_ielts_log_list" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.list</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="IELTS Generation Logs">
|
||||
<field name="skill"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'examiner_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
|
||||
<field name="attempts"/>
|
||||
<field name="examiner_id"/>
|
||||
<field name="created_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_ielts_log" model="ir.actions.act_window">
|
||||
<field name="name">IELTS Generation Logs</field>
|
||||
<field name="res_model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
2
new_project/custom_addons/encoach_branding/__init__.py
Normal file
2
new_project/custom_addons/encoach_branding/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
15
new_project/custom_addons/encoach_branding/__manifest__.py
Normal file
15
new_project/custom_addons/encoach_branding/__manifest__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
'name': 'EnCoach Branding',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Whitelabeling and custom branding per entity',
|
||||
'author': 'EnCoach',
|
||||
'depends': ['encoach_core'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/branding_views.xml',
|
||||
'views/branding_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import branding
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,40 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachBranding(models.Model):
|
||||
_name = 'encoach.branding'
|
||||
_description = 'Entity Branding'
|
||||
|
||||
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade')
|
||||
primary_color = fields.Char(default='#4F46E5')
|
||||
secondary_color = fields.Char(default='#7C3AED')
|
||||
background_color = fields.Char(default='#FFFFFF')
|
||||
logo = fields.Binary(attachment=True)
|
||||
logo_url = fields.Char(string='Logo URL')
|
||||
favicon = fields.Binary(attachment=True)
|
||||
app_name = fields.Char(default='EnCoach')
|
||||
white_label_domain = fields.Char(string='Subdomain')
|
||||
login_title = fields.Char(default='Welcome to EnCoach')
|
||||
login_description = fields.Text()
|
||||
custom_css = fields.Text()
|
||||
|
||||
_entity_unique = models.Constraint('UNIQUE(entity_id)', 'Branding record must be unique per entity.')
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'entity_id': self.entity_id.id,
|
||||
'entity_name': self.entity_id.name,
|
||||
'primary_color': self.primary_color,
|
||||
'secondary_color': self.secondary_color,
|
||||
'background_color': self.background_color or '#FFFFFF',
|
||||
'has_logo': bool(self.logo),
|
||||
'logo_url': self.logo_url or '',
|
||||
'has_favicon': bool(self.favicon),
|
||||
'app_name': self.app_name,
|
||||
'white_label_domain': self.white_label_domain or '',
|
||||
'login_title': self.login_title or '',
|
||||
'login_description': self.login_description or '',
|
||||
'custom_css': self.custom_css or '',
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_branding"
|
||||
name="White-Label Branding"
|
||||
parent="encoach_core.menu_encoach_entity"
|
||||
action="encoach_branding.action_branding"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_branding_form" model="ir.ui.view">
|
||||
<field name="name">encoach.branding.form</field>
|
||||
<field name="model">encoach.branding</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Branding">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="entity_id"/>
|
||||
<field name="app_name"/>
|
||||
<field name="white_label_domain"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="logo" widget="image" class="oe_avatar"/>
|
||||
<field name="logo_url"/>
|
||||
<field name="favicon" widget="image" class="oe_avatar"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Colors">
|
||||
<group>
|
||||
<field name="primary_color" widget="color"/>
|
||||
<field name="secondary_color" widget="color"/>
|
||||
<field name="background_color" widget="color"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Login Page">
|
||||
<field name="login_title"/>
|
||||
<field name="login_description"/>
|
||||
</group>
|
||||
<group string="Custom CSS">
|
||||
<field name="custom_css"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_branding_list" model="ir.ui.view">
|
||||
<field name="name">encoach.branding.list</field>
|
||||
<field name="model">encoach.branding</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Branding">
|
||||
<field name="entity_id"/>
|
||||
<field name="app_name"/>
|
||||
<field name="primary_color"/>
|
||||
<field name="secondary_color"/>
|
||||
<field name="white_label_domain"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_branding" model="ir.actions.act_window">
|
||||
<field name="name">White-Label Branding</field>
|
||||
<field name="res_model">encoach.branding</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
26
new_project/custom_addons/encoach_core/__manifest__.py
Normal file
26
new_project/custom_addons/encoach_core/__manifest__.py
Normal 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',
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class EncoachEntity(models.Model):
|
||||
_name = 'encoach.entity'
|
||||
_description = 'EnCoach Entity'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(required=True, tracking=True)
|
||||
code = fields.Char(required=True, tracking=True)
|
||||
type = fields.Selection([
|
||||
('corporate', 'Corporate'),
|
||||
('university', 'University'),
|
||||
('school', 'School'),
|
||||
('government', 'Government'),
|
||||
('freelance', 'Freelance'),
|
||||
], tracking=True)
|
||||
logo = fields.Binary(attachment=True)
|
||||
logo_url = fields.Char(string='Logo URL')
|
||||
primary_color = fields.Char(default='#4F46E5')
|
||||
secondary_color = fields.Char(default='#7C3AED')
|
||||
background_color = fields.Char(default='#FFFFFF')
|
||||
white_label_domain = fields.Char(string='White Label Domain')
|
||||
login_title = fields.Char()
|
||||
login_description = fields.Text()
|
||||
favicon = fields.Binary(attachment=True)
|
||||
results_release_mode = fields.Selection([
|
||||
('auto', 'Auto'),
|
||||
('manual_approval', 'Manual Approval'),
|
||||
], default='auto')
|
||||
active = fields.Boolean(default=True)
|
||||
user_ids = fields.Many2many('res.users', 'encoach_entity_user_rel', 'entity_id', 'user_id', string='Users')
|
||||
role_ids = fields.One2many('encoach.role', 'entity_id', string='Roles')
|
||||
user_count = fields.Integer(compute='_compute_user_count', store=True)
|
||||
|
||||
_code_unique = models.Constraint('UNIQUE(code)', 'Entity code must be unique.')
|
||||
|
||||
@api.depends('user_ids')
|
||||
def _compute_user_count(self):
|
||||
for rec in self:
|
||||
rec.user_count = len(rec.user_ids)
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'type': self.type or '',
|
||||
'logo': bool(self.logo),
|
||||
'logo_url': self.logo_url or '',
|
||||
'primary_color': self.primary_color or '#4F46E5',
|
||||
'secondary_color': self.secondary_color or '#7C3AED',
|
||||
'background_color': self.background_color or '#FFFFFF',
|
||||
'white_label_domain': self.white_label_domain or '',
|
||||
'login_title': self.login_title or '',
|
||||
'login_description': self.login_description or '',
|
||||
'has_favicon': bool(self.favicon),
|
||||
'results_release_mode': self.results_release_mode or 'auto',
|
||||
'active': self.active,
|
||||
'user_count': self.user_count,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
USER_TYPE_SELECTION = [
|
||||
('student', 'Student'),
|
||||
('teacher', 'Teacher'),
|
||||
('admin', 'Admin'),
|
||||
('corporate', 'Corporate'),
|
||||
('mastercorporate', 'Master Corporate'),
|
||||
('agent', 'Agent'),
|
||||
('developer', 'Developer'),
|
||||
]
|
||||
|
||||
|
||||
class EncoachUser(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
user_type = fields.Selection(USER_TYPE_SELECTION, string='User Type')
|
||||
is_verified = fields.Boolean(default=False)
|
||||
entity_ids = fields.Many2many('encoach.entity', 'encoach_entity_user_rel', 'user_id', 'entity_id', string='Entities')
|
||||
phone = fields.Char()
|
||||
birth_date = fields.Date()
|
||||
gender = fields.Selection([('m', 'Male'), ('f', 'Female'), ('o', 'Other')])
|
||||
op_student_id = fields.Many2one('op.student', string='OpenEduCat Student')
|
||||
op_faculty_id = fields.Many2one('op.faculty', string='OpenEduCat Faculty')
|
||||
encoach_avatar = fields.Binary(attachment=True, string='EnCoach Avatar')
|
||||
last_login = fields.Datetime()
|
||||
role_ids = fields.Many2many(
|
||||
'encoach.role', 'encoach_role_user_rel', 'user_id', 'role_id', string='Roles',
|
||||
)
|
||||
permission_direct_ids = fields.Many2many(
|
||||
'encoach.permission', 'encoach_user_permission_rel', 'user_id', 'permission_id',
|
||||
string='Direct Permissions',
|
||||
)
|
||||
|
||||
first_login = fields.Boolean(default=True)
|
||||
account_source = fields.Selection([
|
||||
('self_registered', 'Self Registered'),
|
||||
('entity_bulk_upload', 'Entity Bulk Upload'),
|
||||
])
|
||||
account_status = fields.Selection([
|
||||
('unactivated', 'Unactivated'),
|
||||
('activated', 'Activated'),
|
||||
('suspended', 'Suspended'),
|
||||
], default='unactivated')
|
||||
|
||||
def get_all_permissions(self):
|
||||
"""Return the union of direct permissions and permissions from all assigned roles."""
|
||||
self.ensure_one()
|
||||
role_permissions = self.role_ids.mapped('permission_ids')
|
||||
return self.permission_direct_ids | role_permissions
|
||||
|
||||
def _api_user_type(self):
|
||||
"""Frontend routes require a non-empty role; many users have no encoach.user_type set yet."""
|
||||
self.ensure_one()
|
||||
if self.user_type:
|
||||
return self.user_type
|
||||
if self.op_faculty_id:
|
||||
return 'teacher'
|
||||
if self.op_student_id:
|
||||
return 'student'
|
||||
if self.has_group('base.group_system'):
|
||||
return 'admin'
|
||||
return ''
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'email': self.email or '',
|
||||
'login': self.login or '',
|
||||
'user_type': self._api_user_type(),
|
||||
'is_verified': self.is_verified,
|
||||
'phone': self.phone or '',
|
||||
'birth_date': self.birth_date.isoformat() if self.birth_date else None,
|
||||
'gender': self.gender or '',
|
||||
'avatar': bool(self.encoach_avatar),
|
||||
'entities': [
|
||||
{'id': e.id, 'name': e.name, 'logo': bool(e.logo)}
|
||||
for e in self.entity_ids
|
||||
],
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||
'first_login': self.first_login,
|
||||
'account_source': self.account_source or '',
|
||||
'account_status': self.account_status or 'unactivated',
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachEntityLevelMapping(models.Model):
|
||||
_name = 'encoach.entity.level.mapping'
|
||||
_description = 'Entity Level Mapping'
|
||||
|
||||
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade', index=True)
|
||||
min_score = fields.Float(required=True)
|
||||
max_score = fields.Float(required=True)
|
||||
internal_level_name = fields.Char(size=100, required=True)
|
||||
cefr_equivalent = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
|
||||
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
|
||||
])
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,16 @@
|
||||
.o_encoach_dashboard .card {
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.o_encoach_dashboard .card.cursor-pointer:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.o_encoach_dashboard h2 {
|
||||
color: #1f2937;
|
||||
font-weight: 700;
|
||||
}
|
||||
.o_encoach_dashboard .badge {
|
||||
text-transform: capitalize;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_core.Dashboard">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_encoach_dashboard">
|
||||
<div class="container-fluid py-4">
|
||||
<h2 class="mb-4">EnCoach Dashboard</h2>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"/>
|
||||
<p class="mt-2">Loading dashboard...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- KPI Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openStudents">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 p-3 me-3">
|
||||
<i class="fa fa-users fa-2x text-primary"/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0" t-esc="state.stats.total_students"/>
|
||||
<small class="text-muted">Students</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openTeachers">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
|
||||
<i class="fa fa-chalkboard-teacher fa-2x text-success"/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0" t-esc="state.stats.total_teachers"/>
|
||||
<small class="text-muted">Teachers</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openCourses">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-info bg-opacity-10 p-3 me-3">
|
||||
<i class="fa fa-book fa-2x text-info"/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0" t-esc="state.stats.total_courses"/>
|
||||
<small class="text-muted">Courses</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openExams">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-warning bg-opacity-10 p-3 me-3">
|
||||
<i class="fa fa-file-text fa-2x text-warning"/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0" t-esc="state.stats.total_exams"/>
|
||||
<small class="text-muted">Exams</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card shadow-sm border-start border-danger border-4 cursor-pointer" t-on-click="openGradingQueue">
|
||||
<div class="card-body">
|
||||
<h5>Grading Queue</h5>
|
||||
<h2 class="text-danger" t-esc="state.stats.pending_grading"/>
|
||||
<small class="text-muted">Exams awaiting grading</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card shadow-sm border-start border-warning border-4 cursor-pointer" t-on-click="openPendingApproval">
|
||||
<div class="card-body">
|
||||
<h5>Pending Approval</h5>
|
||||
<h2 class="text-warning" t-esc="state.stats.pending_approval"/>
|
||||
<small class="text-muted">Scores awaiting release</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card shadow-sm border-start border-info border-4 cursor-pointer" t-on-click="openEntities">
|
||||
<div class="card-body">
|
||||
<h5>Entities</h5>
|
||||
<h2 class="text-info" t-esc="state.stats.total_entities"/>
|
||||
<small class="text-muted">Registered organizations</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Attempts Table -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">Recent Exam Attempts</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Exam</th>
|
||||
<th>Status</th>
|
||||
<th>Band</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.recentAttempts" t-as="attempt" t-key="attempt.id">
|
||||
<tr>
|
||||
<td t-esc="attempt.student_id[1]"/>
|
||||
<td t-esc="attempt.exam_id ? attempt.exam_id[1] : '-'"/>
|
||||
<td>
|
||||
<span class="badge"
|
||||
t-att-class="{
|
||||
'bg-success': attempt.status === 'released',
|
||||
'bg-warning': attempt.status === 'pending_approval',
|
||||
'bg-info': attempt.status === 'scoring',
|
||||
'bg-secondary': attempt.status === 'in_progress',
|
||||
'bg-primary': attempt.status === 'scored'
|
||||
}"
|
||||
t-esc="attempt.status"/>
|
||||
</td>
|
||||
<td t-esc="attempt.overall_band || '-'"/>
|
||||
<td t-esc="attempt.started_at"/>
|
||||
</tr>
|
||||
</t>
|
||||
<t t-if="!state.recentAttempts.length">
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">No recent attempts</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="action_encoach_dashboard" model="ir.actions.client">
|
||||
<field name="name">EnCoach Dashboard</field>
|
||||
<field name="tag">encoach_dashboard</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_encoach_dashboard_action"
|
||||
name="Dashboard"
|
||||
parent="menu_encoach_dashboard"
|
||||
action="action_encoach_dashboard"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem id="menu_encoach_root" name="EnCoach" sequence="10" web_icon="encoach_core,static/description/icon.png"/>
|
||||
|
||||
<menuitem id="menu_encoach_dashboard" name="Dashboard" parent="menu_encoach_root" sequence="1"/>
|
||||
<menuitem id="menu_encoach_exams" name="Exams" parent="menu_encoach_root" sequence="10"/>
|
||||
<menuitem id="menu_encoach_courses" name="Courses" parent="menu_encoach_root" sequence="20"/>
|
||||
<menuitem id="menu_encoach_students" name="Students" parent="menu_encoach_root" sequence="30"/>
|
||||
<menuitem id="menu_encoach_lms" name="LMS" parent="menu_encoach_root" sequence="40"/>
|
||||
<menuitem id="menu_encoach_content" name="Content" parent="menu_encoach_root" sequence="50"/>
|
||||
<menuitem id="menu_encoach_entity" name="Entity Management" parent="menu_encoach_root" sequence="60"/>
|
||||
<menuitem id="menu_encoach_reports" name="Reports" parent="menu_encoach_root" sequence="70"/>
|
||||
<menuitem id="menu_encoach_config" name="Configuration" parent="menu_encoach_root" sequence="80"/>
|
||||
<menuitem id="menu_encoach_support" name="Support" parent="menu_encoach_root" sequence="90"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_entity_level_mapping_form" model="ir.ui.view">
|
||||
<field name="name">encoach.entity.level.mapping.form</field>
|
||||
<field name="model">encoach.entity.level.mapping</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Entity Level Mapping">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="entity_id"/>
|
||||
<field name="internal_level_name"/>
|
||||
<field name="cefr_equivalent"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="min_score"/>
|
||||
<field name="max_score"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_entity_level_mapping_list" model="ir.ui.view">
|
||||
<field name="name">encoach.entity.level.mapping.list</field>
|
||||
<field name="model">encoach.entity.level.mapping</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Entity Level Mappings" editable="bottom">
|
||||
<field name="entity_id"/>
|
||||
<field name="internal_level_name"/>
|
||||
<field name="min_score"/>
|
||||
<field name="max_score"/>
|
||||
<field name="cefr_equivalent"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_entity_level_mapping" model="ir.actions.act_window">
|
||||
<field name="name">Entity Level Mappings</field>
|
||||
<field name="res_model">encoach.entity.level.mapping</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_entity_level_mapping"
|
||||
name="Entity Level Mappings"
|
||||
parent="encoach_core.menu_encoach_entity"
|
||||
action="encoach_core.action_entity_level_mapping"
|
||||
sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
2
new_project/custom_addons/encoach_course_gen/__init__.py
Normal file
2
new_project/custom_addons/encoach_course_gen/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
26
new_project/custom_addons/encoach_course_gen/__manifest__.py
Normal file
26
new_project/custom_addons/encoach_course_gen/__manifest__.py
Normal 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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import course
|
||||
@@ -0,0 +1,630 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
CEFR_ORDER = ['pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2']
|
||||
|
||||
STEP_DOWN_THRESHOLD = 0.4
|
||||
PASS_THRESHOLD_DEFAULT = 0.7
|
||||
|
||||
|
||||
def _cefr_index(level):
|
||||
try:
|
||||
return CEFR_ORDER.index(level)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _next_cefr(level):
|
||||
idx = _cefr_index(level)
|
||||
return CEFR_ORDER[min(idx + 1, len(CEFR_ORDER) - 1)]
|
||||
|
||||
|
||||
def _module_to_dict(mod):
|
||||
return {
|
||||
'id': mod.id,
|
||||
'name': mod.name,
|
||||
'cefr_target': mod.cefr_target or '',
|
||||
'auto_generated': mod.auto_generated,
|
||||
'completion_criteria': mod.completion_criteria or 'all_resources',
|
||||
'score_threshold': mod.score_threshold,
|
||||
'prerequisite_module_id': mod.prerequisite_module_id.id if mod.prerequisite_module_id else None,
|
||||
'status': mod.status,
|
||||
'sequence': mod.sequence,
|
||||
'generation_brief': mod.generation_brief or '',
|
||||
}
|
||||
|
||||
|
||||
def _course_to_dict(course, include_modules=True):
|
||||
data = {
|
||||
'id': course.id,
|
||||
'name': course.name,
|
||||
'code': course.code if hasattr(course, 'code') and course.code else '',
|
||||
'description': course.description if hasattr(course, 'description') else '',
|
||||
'generation_source': course.generation_source if hasattr(course, 'generation_source') else '',
|
||||
'progression_model': course.progression_model if hasattr(course, 'progression_model') else 'linear',
|
||||
'target_band': course.target_band if hasattr(course, 'target_band') else 0.0,
|
||||
'entity_id': course.entity_id.id if hasattr(course, 'entity_id') and course.entity_id else None,
|
||||
}
|
||||
if include_modules:
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
modules = Module.search([('course_id', '=', course.id)], order='sequence asc')
|
||||
data['modules'] = [_module_to_dict(m) for m in modules]
|
||||
return data
|
||||
|
||||
|
||||
class EncoachCourseController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/create
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/create', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
name = (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return _error_response('name is required', 400)
|
||||
|
||||
Course = request.env['op.course'].sudo()
|
||||
course_vals = {
|
||||
'name': name,
|
||||
'code': body.get('code', name[:20].upper().replace(' ', '_')),
|
||||
}
|
||||
if body.get('subject_id'):
|
||||
course_vals['subject_id'] = int(body['subject_id'])
|
||||
if body.get('description') and hasattr(Course, 'description'):
|
||||
course_vals['description'] = body['description']
|
||||
if hasattr(Course, 'generation_source'):
|
||||
course_vals['generation_source'] = 'manual'
|
||||
if hasattr(Course, 'entity_id'):
|
||||
user = request.env.user.sudo()
|
||||
if user.entity_ids:
|
||||
course_vals['entity_id'] = user.entity_ids[0].id
|
||||
|
||||
course = Course.create(course_vals)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
modules_data = body.get('modules', [])
|
||||
for idx, mod_data in enumerate(modules_data):
|
||||
Module.create({
|
||||
'name': mod_data.get('name', f'Module {idx + 1}'),
|
||||
'course_id': course.id,
|
||||
'cefr_target': mod_data.get('cefr_target', ''),
|
||||
'completion_criteria': mod_data.get('completion_criteria', 'all_resources'),
|
||||
'score_threshold': mod_data.get('score_threshold', 0.0),
|
||||
'sequence': mod_data.get('sequence', (idx + 1) * 10),
|
||||
'status': 'available' if idx == 0 else 'locked',
|
||||
})
|
||||
|
||||
return _json_response(_course_to_dict(course), 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('course create failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/course/gap-analysis
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/gap-analysis', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def gap_analysis(self, **kw):
|
||||
try:
|
||||
uid = request.env.user.id
|
||||
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||
profile = GapProfile.search([
|
||||
('student_id', '=', uid),
|
||||
], limit=1, order='created_at desc')
|
||||
|
||||
if not profile:
|
||||
CatSession = request.env['encoach.cat.session'].sudo()
|
||||
session = CatSession.search([
|
||||
('student_id', '=', uid),
|
||||
('status', '=', 'completed'),
|
||||
], limit=1, order='completed_at desc')
|
||||
|
||||
if not session:
|
||||
return _error_response('No placement results or gap profile found', 404)
|
||||
|
||||
Ability = request.env['encoach.student.ability.model'].sudo()
|
||||
abilities = Ability.search([('student_id', '=', uid)])
|
||||
|
||||
skill_gaps = {}
|
||||
for ab in abilities:
|
||||
if ab.theta < 0.5:
|
||||
skill_gaps[ab.skill] = {
|
||||
'theta': ab.theta,
|
||||
'sem': ab.sem,
|
||||
'gap_severity': 'high' if ab.theta < -0.5 else 'medium' if ab.theta < 0.0 else 'low',
|
||||
}
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
placement_attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('is_placement', '=', True),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
question_type_weaknesses = {}
|
||||
topic_weaknesses = {}
|
||||
if placement_attempt:
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
answers = Answer.search([('attempt_id', '=', placement_attempt.id)])
|
||||
type_stats = {}
|
||||
topic_stats = {}
|
||||
for ans in answers:
|
||||
q = ans.question_id
|
||||
qt = q.question_type or 'unknown'
|
||||
type_stats.setdefault(qt, {'correct': 0, 'total': 0})
|
||||
type_stats[qt]['total'] += 1
|
||||
if ans.is_correct:
|
||||
type_stats[qt]['correct'] += 1
|
||||
if q.topic_id:
|
||||
tid = q.topic_id.name or str(q.topic_id.id)
|
||||
topic_stats.setdefault(tid, {'correct': 0, 'total': 0})
|
||||
topic_stats[tid]['total'] += 1
|
||||
if ans.is_correct:
|
||||
topic_stats[tid]['correct'] += 1
|
||||
|
||||
for qt, stats in type_stats.items():
|
||||
pct = stats['correct'] / stats['total'] if stats['total'] else 0
|
||||
if pct < 0.6:
|
||||
question_type_weaknesses[qt] = round(pct, 2)
|
||||
|
||||
for topic, stats in topic_stats.items():
|
||||
pct = stats['correct'] / stats['total'] if stats['total'] else 0
|
||||
if pct < 0.6:
|
||||
topic_weaknesses[topic] = round(pct, 2)
|
||||
|
||||
user = request.env.user.sudo()
|
||||
entity_id = user.entity_ids[0].id if user.entity_ids else False
|
||||
|
||||
profile = GapProfile.create({
|
||||
'student_id': uid,
|
||||
'source_type': 'placement',
|
||||
'source_id': session.id,
|
||||
'skill_gaps': json.dumps(skill_gaps),
|
||||
'question_type_weaknesses': json.dumps(question_type_weaknesses),
|
||||
'topic_weaknesses': json.dumps(topic_weaknesses),
|
||||
'entity_id': entity_id,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'gap_profile_id': profile.id,
|
||||
'student_id': profile.student_id.id,
|
||||
'source_type': profile.source_type,
|
||||
'skill_gaps': json.loads(profile.skill_gaps) if profile.skill_gaps else {},
|
||||
'question_type_weaknesses': json.loads(profile.question_type_weaknesses) if profile.question_type_weaknesses else {},
|
||||
'topic_weaknesses': json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {},
|
||||
'created_at': profile.created_at,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('gap_analysis failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/auto-generate
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/auto-generate', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def auto_generate(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
uid = request.env.user.id
|
||||
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||
|
||||
gap_profile_id = body.get('gap_profile_id')
|
||||
if gap_profile_id:
|
||||
profile = GapProfile.browse(int(gap_profile_id))
|
||||
if not profile.exists():
|
||||
return _error_response('Gap profile not found', 404)
|
||||
else:
|
||||
profile = GapProfile.search([
|
||||
('student_id', '=', uid),
|
||||
], limit=1, order='created_at desc')
|
||||
if not profile:
|
||||
return _error_response('No gap profile found. Run gap-analysis first.', 404)
|
||||
|
||||
skill_gaps = json.loads(profile.skill_gaps) if profile.skill_gaps else {}
|
||||
topic_weaknesses = json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {}
|
||||
|
||||
Ability = request.env['encoach.student.ability.model'].sudo()
|
||||
abilities = Ability.search([('student_id', '=', uid)])
|
||||
ability_map = {}
|
||||
for ab in abilities:
|
||||
ability_map[ab.skill] = ab.theta
|
||||
|
||||
user = request.env.user.sudo()
|
||||
entity_id = user.entity_ids[0].id if user.entity_ids else False
|
||||
|
||||
Course = request.env['op.course'].sudo()
|
||||
course_vals = {
|
||||
'name': f"Personalized Course for {user.name}",
|
||||
'code': f"AUTO_{uid}_{profile.id}",
|
||||
}
|
||||
if hasattr(Course, 'generation_source'):
|
||||
course_vals['generation_source'] = 'auto_gap'
|
||||
if hasattr(Course, 'gap_profile_id'):
|
||||
course_vals['gap_profile_id'] = profile.id
|
||||
if hasattr(Course, 'entity_id'):
|
||||
course_vals['entity_id'] = entity_id
|
||||
|
||||
course = Course.create(course_vals)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
seq = 10
|
||||
created_modules = []
|
||||
|
||||
sorted_gaps = sorted(skill_gaps.items(),
|
||||
key=lambda x: x[1].get('theta', 0) if isinstance(x[1], dict) else 0)
|
||||
|
||||
for skill, gap_info in sorted_gaps:
|
||||
theta = gap_info.get('theta', 0) if isinstance(gap_info, dict) else 0
|
||||
if theta < -0.5:
|
||||
cefr = 'a1'
|
||||
elif theta < 0.0:
|
||||
cefr = 'a2'
|
||||
elif theta < 0.5:
|
||||
cefr = 'b1'
|
||||
else:
|
||||
cefr = 'b2'
|
||||
|
||||
severity = gap_info.get('gap_severity', 'medium') if isinstance(gap_info, dict) else 'medium'
|
||||
mod = Module.create({
|
||||
'name': f"{skill.title()} Improvement ({severity})",
|
||||
'course_id': course.id,
|
||||
'cefr_target': cefr,
|
||||
'auto_generated': True,
|
||||
'generation_brief': json.dumps({
|
||||
'skill': skill,
|
||||
'theta': theta,
|
||||
'severity': severity,
|
||||
}),
|
||||
'completion_criteria': 'score_threshold',
|
||||
'score_threshold': PASS_THRESHOLD_DEFAULT,
|
||||
'sequence': seq,
|
||||
'status': 'available' if seq == 10 else 'locked',
|
||||
})
|
||||
created_modules.append(mod)
|
||||
seq += 10
|
||||
|
||||
for topic_name, accuracy in sorted(topic_weaknesses.items(), key=lambda x: x[1]):
|
||||
mod = Module.create({
|
||||
'name': f"Topic Review: {topic_name}",
|
||||
'course_id': course.id,
|
||||
'cefr_target': 'b1',
|
||||
'auto_generated': True,
|
||||
'generation_brief': json.dumps({
|
||||
'topic': topic_name,
|
||||
'accuracy': accuracy,
|
||||
}),
|
||||
'completion_criteria': 'all_resources',
|
||||
'sequence': seq,
|
||||
'status': 'locked',
|
||||
})
|
||||
created_modules.append(mod)
|
||||
seq += 10
|
||||
|
||||
if not created_modules:
|
||||
Module.create({
|
||||
'name': 'General Practice',
|
||||
'course_id': course.id,
|
||||
'cefr_target': 'b1',
|
||||
'auto_generated': True,
|
||||
'completion_criteria': 'all_resources',
|
||||
'sequence': 10,
|
||||
'status': 'available',
|
||||
})
|
||||
|
||||
for i in range(1, len(created_modules)):
|
||||
created_modules[i].write({
|
||||
'prerequisite_module_id': created_modules[i - 1].id,
|
||||
})
|
||||
|
||||
return _json_response(_course_to_dict(course), 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('auto_generate failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/course/<int:course_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get(self, course_id, **kw):
|
||||
try:
|
||||
Course = request.env['op.course'].sudo()
|
||||
course = Course.browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
|
||||
data = _course_to_dict(course)
|
||||
|
||||
uid = request.env.user.id
|
||||
Progress = request.env.get('encoach.course.progress')
|
||||
if Progress is not None:
|
||||
Progress = Progress.sudo()
|
||||
for mod in data.get('modules', []):
|
||||
progress_recs = Progress.search([
|
||||
('student_id', '=', uid),
|
||||
('module_id', '=', mod['id']),
|
||||
])
|
||||
mod['completed_resources'] = len(
|
||||
progress_recs.filtered(lambda p: p.completed if hasattr(p, 'completed') else False)
|
||||
)
|
||||
|
||||
return _json_response(data)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('course get failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/course/<int:course_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>', type='http', auth='none',
|
||||
methods=['PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update(self, course_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
Course = request.env['op.course'].sudo()
|
||||
course = Course.browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
|
||||
allowed_fields = {'name', 'description', 'code'}
|
||||
vals = {}
|
||||
for key in allowed_fields:
|
||||
if key in body and hasattr(course, key):
|
||||
vals[key] = body[key]
|
||||
|
||||
if vals:
|
||||
course.write(vals)
|
||||
|
||||
module_order = body.get('module_order')
|
||||
if module_order and isinstance(module_order, list):
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
for idx, mod_id in enumerate(module_order):
|
||||
mod = Module.browse(int(mod_id))
|
||||
if mod.exists() and mod.course_id.id == course.id:
|
||||
mod.write({'sequence': (idx + 1) * 10})
|
||||
|
||||
return _json_response(_course_to_dict(course))
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('course update failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/<int:course_id>/progress
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>/progress', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def progress(self, course_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
module_id = body.get('module_id')
|
||||
resource_id = body.get('resource_id')
|
||||
completed = body.get('completed', False)
|
||||
|
||||
if not module_id:
|
||||
return _error_response('module_id is required', 400)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
mod = Module.browse(int(module_id))
|
||||
if not mod.exists() or mod.course_id.id != course_id:
|
||||
return _error_response('Module not found in this course', 404)
|
||||
|
||||
uid = request.env.user.id
|
||||
|
||||
if mod.status == 'locked':
|
||||
return _error_response('Module is locked', 400)
|
||||
|
||||
if mod.status in ('locked', 'available'):
|
||||
mod.write({'status': 'in_progress'})
|
||||
|
||||
Progress = request.env.get('encoach.course.progress')
|
||||
if Progress is not None and resource_id:
|
||||
Progress = Progress.sudo()
|
||||
existing = Progress.search([
|
||||
('student_id', '=', uid),
|
||||
('module_id', '=', mod.id),
|
||||
('resource_id', '=', int(resource_id)),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write({'completed': completed})
|
||||
else:
|
||||
Progress.create({
|
||||
'student_id': uid,
|
||||
'module_id': mod.id,
|
||||
'resource_id': int(resource_id),
|
||||
'completed': completed,
|
||||
})
|
||||
|
||||
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
|
||||
total_modules = len(all_modules)
|
||||
completed_modules = len(all_modules.filtered(lambda m: m.status == 'completed'))
|
||||
|
||||
module_status = mod.status
|
||||
if mod.completion_criteria == 'all_resources' and Progress is not None:
|
||||
Progress = request.env['encoach.course.progress'].sudo()
|
||||
mod_progress = Progress.search([
|
||||
('student_id', '=', uid),
|
||||
('module_id', '=', mod.id),
|
||||
])
|
||||
all_done = all(
|
||||
getattr(p, 'completed', False) for p in mod_progress
|
||||
) if mod_progress else False
|
||||
if all_done and mod_progress:
|
||||
mod.write({'status': 'completed'})
|
||||
module_status = 'completed'
|
||||
completed_modules += 1
|
||||
self._unlock_next_module(mod, all_modules)
|
||||
|
||||
overall_pct = round((completed_modules / total_modules) * 100, 1) if total_modules else 0.0
|
||||
|
||||
return _json_response({
|
||||
'module_id': mod.id,
|
||||
'module_status': module_status,
|
||||
'overall_progress_pct': overall_pct,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('progress failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
def _unlock_next_module(self, current_mod, all_modules):
|
||||
"""Unlock the next sequential module when the current one is completed."""
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
next_mods = Module.search([
|
||||
('course_id', '=', current_mod.course_id.id),
|
||||
('prerequisite_module_id', '=', current_mod.id),
|
||||
('status', '=', 'locked'),
|
||||
])
|
||||
for nm in next_mods:
|
||||
nm.write({'status': 'available'})
|
||||
|
||||
if not next_mods:
|
||||
found_current = False
|
||||
for m in all_modules:
|
||||
if found_current and m.status == 'locked':
|
||||
m.write({'status': 'available'})
|
||||
break
|
||||
if m.id == current_mod.id:
|
||||
found_current = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/<int:course_id>/checkpoint
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>/checkpoint', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def checkpoint(self, course_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
module_id = body.get('module_id')
|
||||
score = body.get('score')
|
||||
|
||||
if not module_id or score is None:
|
||||
return _error_response('module_id and score are required', 400)
|
||||
|
||||
score = float(score)
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
mod = Module.browse(int(module_id))
|
||||
if not mod.exists() or mod.course_id.id != course_id:
|
||||
return _error_response('Module not found in this course', 404)
|
||||
|
||||
threshold = mod.score_threshold or PASS_THRESHOLD_DEFAULT
|
||||
passed = score >= threshold
|
||||
|
||||
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
|
||||
next_module_id = None
|
||||
action = 'continue'
|
||||
|
||||
if passed:
|
||||
mod.write({'status': 'completed'})
|
||||
self._unlock_next_module(mod, all_modules)
|
||||
|
||||
next_mods = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('status', 'in', ['available', 'locked']),
|
||||
('sequence', '>', mod.sequence),
|
||||
], limit=1, order='sequence asc')
|
||||
if next_mods:
|
||||
next_module_id = next_mods[0].id
|
||||
action = 'next_module'
|
||||
else:
|
||||
action = 'course_complete'
|
||||
elif score < STEP_DOWN_THRESHOLD:
|
||||
action = 'remediation'
|
||||
else:
|
||||
action = 'retry'
|
||||
|
||||
return _json_response({
|
||||
'module_id': mod.id,
|
||||
'score': score,
|
||||
'threshold': threshold,
|
||||
'passed': passed,
|
||||
'next_module_id': next_module_id,
|
||||
'action': action,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('checkpoint failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/<int:course_id>/post-test
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>/post-test', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def post_test(self, course_id, **kw):
|
||||
try:
|
||||
Course = request.env['op.course'].sudo()
|
||||
course = Course.browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
all_modules = Module.search([('course_id', '=', course_id)])
|
||||
incomplete = all_modules.filtered(lambda m: m.status != 'completed')
|
||||
if incomplete:
|
||||
return _error_response(
|
||||
f'{len(incomplete)} module(s) not completed. Finish all modules first.', 400
|
||||
)
|
||||
|
||||
subject_id = course.subject_id.id if hasattr(course, 'subject_id') and course.subject_id else False
|
||||
|
||||
ExamCustom = request.env['encoach.exam.custom'].sudo()
|
||||
exam = None
|
||||
if subject_id:
|
||||
exam = ExamCustom.search([
|
||||
('subject_id', '=', subject_id),
|
||||
('status', '=', 'published'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not exam:
|
||||
exam = ExamCustom.search([
|
||||
('status', '=', 'published'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not exam:
|
||||
return _error_response('No published exam found for post-test', 404)
|
||||
|
||||
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||
uid = request.env.user.id
|
||||
|
||||
existing = Assignment.search([
|
||||
('exam_id', '=', exam.id),
|
||||
('student_id', '=', uid),
|
||||
('status', '=', 'assigned'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return _json_response({'exam_assignment_id': existing.id})
|
||||
|
||||
assignment = Assignment.create({
|
||||
'exam_id': exam.id,
|
||||
'student_id': uid,
|
||||
'status': 'assigned',
|
||||
})
|
||||
|
||||
return _json_response({'exam_assignment_id': assignment.id}, 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('post_test failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import gap_profile
|
||||
from . import course_extension
|
||||
from . import course_module
|
||||
@@ -0,0 +1,21 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class OpCourseExtension(models.Model):
|
||||
_inherit = 'op.course'
|
||||
|
||||
generation_source = fields.Selection([
|
||||
('manual', 'Manual'),
|
||||
('auto_gap', 'Auto from Gap'),
|
||||
('ai_english', 'AI English'),
|
||||
('ai_ielts', 'AI IELTS'),
|
||||
])
|
||||
gap_profile_id = fields.Many2one('encoach.gap.profile', ondelete='set null')
|
||||
progression_model = fields.Selection([
|
||||
('linear', 'Linear'),
|
||||
('parallel', 'Parallel'),
|
||||
('adaptive', 'Adaptive'),
|
||||
], default='linear')
|
||||
target_band = fields.Float()
|
||||
study_hours_week = fields.Integer()
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,18 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachGapProfile(models.Model):
|
||||
_name = 'encoach.gap.profile'
|
||||
_description = 'Student Gap Profile'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
source_type = fields.Selection([
|
||||
('placement', 'Placement'),
|
||||
('exam', 'Exam'),
|
||||
], required=True)
|
||||
source_id = fields.Integer()
|
||||
skill_gaps = fields.Text()
|
||||
question_type_weaknesses = fields.Text()
|
||||
topic_weaknesses = fields.Text()
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
@@ -0,0 +1,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
|
||||
|
@@ -0,0 +1,40 @@
|
||||
.o_gap_analysis .ga-bar-track {
|
||||
height: 28px;
|
||||
background: #e9ecef;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.o_gap_analysis .ga-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
transition: width 0.6s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.o_gap_analysis .ga-bar-label {
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.o_gap_analysis .ga-bar-row {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.o_gap_analysis .ga-student-card {
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_gap_analysis .ga-student-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.o_gap_analysis .card {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.o_gap_analysis .cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_course_gen.GapAnalysis">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_gap_analysis">
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||
<p class="mt-2 text-muted">Analyzing gaps...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Student Selector -->
|
||||
<t t-elif="!state.studentId and state.studentList">
|
||||
<h2 class="mb-3">Gap Analysis</h2>
|
||||
<p class="text-muted mb-4">Select a student to view their skill gap analysis.</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
|
||||
<div class="col-lg-3 col-md-4 col-sm-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer ga-student-card"
|
||||
t-on-click="() => this.selectStudent(st.id)">
|
||||
<div class="card-body text-center">
|
||||
<div class="rounded-circle bg-primary bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
|
||||
<i class="fa fa-user text-primary"/>
|
||||
</div>
|
||||
<h6 class="card-title mb-0" t-esc="st.name"/>
|
||||
<small class="text-muted" t-esc="st.email"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Gap Analysis View -->
|
||||
<t t-elif="state.student">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
|
||||
t-if="!this.props.action?.context?.student_id">
|
||||
<i class="fa fa-arrow-left me-1"/> Back
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="mb-0">Gap Analysis</h2>
|
||||
<small class="text-muted">
|
||||
Student: <strong t-esc="state.student.name"/>
|
||||
<t t-if="state.gapProfile">
|
||||
— Source: <span class="badge bg-secondary" t-esc="state.gapProfile.source_type"/>
|
||||
</t>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="!state.gapProfile">
|
||||
<div class="alert alert-info">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
No gap profile found for this student. A placement test or exam is required first.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Skill Gap Bars -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-bar-chart me-2 text-primary"/>
|
||||
Skill Gaps
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="state.skillGaps.length">
|
||||
<t t-foreach="state.skillGaps" t-as="gap" t-key="gap.skill">
|
||||
<div class="ga-bar-row mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-bold text-capitalize" t-esc="gap.skill"/>
|
||||
<span class="small text-muted">
|
||||
<t t-esc="gap.current"/> / <t t-esc="gap.target"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ga-bar-track">
|
||||
<div class="ga-bar-fill"
|
||||
t-att-class="barColor(gap.current, gap.target)"
|
||||
t-att-style="'width:' + barWidth(gap.current, gap.target) + '%'">
|
||||
<span class="ga-bar-label" t-if="barWidth(gap.current, gap.target) > 15">
|
||||
<t t-esc="barWidth(gap.current, gap.target)"/>%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted mb-0">No skill gap data available.</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Weak Topics -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-warning me-2 text-warning"/>
|
||||
Weak Topics
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="state.weakTopics.length">
|
||||
<t t-foreach="state.weakTopics" t-as="topic" t-key="topic_index">
|
||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
||||
<span>
|
||||
<i class="fa fa-circle text-danger me-2" style="font-size:0.5rem; vertical-align:middle"/>
|
||||
<t t-esc="topic.name || topic"/>
|
||||
</span>
|
||||
<span class="badge bg-danger bg-opacity-75" t-if="topic.score !== undefined">
|
||||
<t t-esc="topic.score"/>%
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted text-center mb-0 py-3">
|
||||
<i class="fa fa-check-circle text-success me-1"/>
|
||||
No weak topics identified
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendations -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-lightbulb-o me-2 text-info"/>
|
||||
Recommended Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-foreach="state.recommendations" t-as="rec" t-key="rec_index">
|
||||
<div class="d-flex align-items-start py-2 border-bottom">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-3 flex-shrink-0"
|
||||
t-att-class="'bg-' + rec.color + ' bg-opacity-10'"
|
||||
style="width:36px; height:36px">
|
||||
<i class="fa" t-att-class="rec.icon + ' text-' + rec.color"/>
|
||||
</div>
|
||||
<span class="small" t-esc="rec.text"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-elif="!state.studentId">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-line-chart fa-3x mb-3 d-block"/>
|
||||
<p>No student selected. Open from a student record or select one above.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_all_courses" model="ir.actions.act_window">
|
||||
<field name="name">All Courses</field>
|
||||
<field name="res_model">op.course</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_all_courses"
|
||||
name="All Courses"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_course_gen.action_all_courses"
|
||||
sequence="5"/>
|
||||
|
||||
<menuitem id="menu_course_modules"
|
||||
name="Course Modules"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_course_gen.action_course_module"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_gap_analysis"
|
||||
name="Gap Analysis"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_course_gen.action_gap_profile"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_gap_analysis_viz" model="ir.actions.client">
|
||||
<field name="name">Gap Analysis</field>
|
||||
<field name="tag">encoach_gap_analysis</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_gap_analysis_viz"
|
||||
name="Gap Analysis (Visual)"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="action_gap_analysis_viz"
|
||||
sequence="25"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import services
|
||||
from . import controllers
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
'name': 'EnCoach Entity Onboarding',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Entity student CSV bulk upload, account creation, credential management',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import entity_onboard
|
||||
@@ -0,0 +1,292 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
|
||||
|
||||
class EncoachEntityOnboardController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/entity/students/validate-csv
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/entity/students/validate-csv', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def validate_csv(self, **kw):
|
||||
try:
|
||||
uploaded = request.httprequest.files.get('file')
|
||||
if not uploaded:
|
||||
return _error_response('CSV file is required (multipart field "file")', 400)
|
||||
|
||||
raw = uploaded.read()
|
||||
from odoo.addons.encoach_entity_onboard.services.csv_parser import CsvParser
|
||||
parser = CsvParser()
|
||||
result = parser.validate(raw)
|
||||
|
||||
valid_rows = result['valid_rows']
|
||||
parse_errors = result['errors']
|
||||
|
||||
row_errors = []
|
||||
for err in parse_errors:
|
||||
row_errors.append({'row': 0, 'field': '', 'message': err})
|
||||
|
||||
User = request.env['res.users'].sudo()
|
||||
dedup_errors = []
|
||||
for idx, row in enumerate(valid_rows):
|
||||
email = row.get('email', '')
|
||||
if email and not EMAIL_RE.match(email):
|
||||
row_errors.append({
|
||||
'row': idx + 2,
|
||||
'field': 'email',
|
||||
'message': f"Invalid email format: {email}",
|
||||
})
|
||||
continue
|
||||
|
||||
if email:
|
||||
existing = User.search([('login', '=', email)], limit=1)
|
||||
if existing:
|
||||
dedup_errors.append({
|
||||
'row': idx + 2,
|
||||
'field': 'email',
|
||||
'message': f"Duplicate email already exists in DB: {email}",
|
||||
})
|
||||
|
||||
all_errors = row_errors + dedup_errors
|
||||
error_count = len(all_errors)
|
||||
truly_valid = [
|
||||
r for i, r in enumerate(valid_rows)
|
||||
if not any(e['row'] == i + 2 for e in all_errors)
|
||||
]
|
||||
|
||||
preview = truly_valid[:5]
|
||||
|
||||
return _json_response({
|
||||
'valid_count': len(truly_valid),
|
||||
'error_count': error_count,
|
||||
'errors': all_errors,
|
||||
'preview': preview,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('validate_csv failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/entity/students/bulk-create
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/entity/students/bulk-create', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def bulk_create(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
rows = body.get('rows', [])
|
||||
entity_id = body.get('entity_id')
|
||||
|
||||
if not rows:
|
||||
return _error_response('rows array is required', 400)
|
||||
if not entity_id:
|
||||
return _error_response('entity_id is required', 400)
|
||||
|
||||
entity_id = int(entity_id)
|
||||
Entity = request.env['encoach.entity'].sudo()
|
||||
entity = Entity.browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
|
||||
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||
service = CredentialService()
|
||||
created_users = service.create_accounts(request.env, rows, entity_id)
|
||||
|
||||
return _json_response({
|
||||
'created_count': len(created_users),
|
||||
'user_ids': created_users.ids,
|
||||
}, 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('bulk_create failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/entity/students/send-credentials
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/entity/students/send-credentials', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def send_credentials(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
user_ids = body.get('user_ids', [])
|
||||
if not user_ids:
|
||||
return _error_response('user_ids array is required', 400)
|
||||
|
||||
user_ids = [int(uid) for uid in user_ids]
|
||||
|
||||
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||
service = CredentialService()
|
||||
service.send_credentials(request.env, user_ids)
|
||||
|
||||
return _json_response({'sent_count': len(user_ids)})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('send_credentials failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/entity/students/<int:student_id>/resend-credentials
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/entity/students/<int:student_id>/resend-credentials',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def resend_credentials(self, student_id, **kw):
|
||||
try:
|
||||
User = request.env['res.users'].sudo()
|
||||
user = User.browse(student_id)
|
||||
if not user.exists():
|
||||
return _error_response('Student not found', 404)
|
||||
|
||||
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||
service = CredentialService()
|
||||
service.resend_credentials(request.env, student_id)
|
||||
|
||||
return _json_response({'sent': True})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('resend_credentials failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/entity/students/resend-all-pending
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/entity/students/resend-all-pending', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def resend_all_pending(self, **kw):
|
||||
try:
|
||||
user = request.env.user.sudo()
|
||||
domain = [
|
||||
('first_login', '=', True),
|
||||
('account_source', '=', 'entity_bulk_upload'),
|
||||
]
|
||||
|
||||
if user.entity_ids:
|
||||
domain.append(('entity_ids', 'in', user.entity_ids.ids))
|
||||
|
||||
User = request.env['res.users'].sudo()
|
||||
pending_users = User.search(domain)
|
||||
|
||||
if not pending_users:
|
||||
return _json_response({'sent_count': 0})
|
||||
|
||||
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
|
||||
service = CredentialService()
|
||||
service.send_credentials(request.env, pending_users.ids)
|
||||
|
||||
return _json_response({'sent_count': len(pending_users)})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('resend_all_pending failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/entity/students/sis-import
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/entity/students/sis-import', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def sis_import(self, **kw):
|
||||
try:
|
||||
sis_data = None
|
||||
uploaded = request.httprequest.files.get('file')
|
||||
if uploaded:
|
||||
raw = uploaded.read()
|
||||
try:
|
||||
sis_data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return _error_response('Invalid JSON file', 400)
|
||||
else:
|
||||
body = _get_json_body()
|
||||
sis_data = body.get('sis_data', [])
|
||||
|
||||
if not sis_data or not isinstance(sis_data, list):
|
||||
return _error_response('sis_data must be a non-empty JSON array', 400)
|
||||
|
||||
user = request.env.user.sudo()
|
||||
entity_id = None
|
||||
if user.entity_ids:
|
||||
entity_id = user.entity_ids[0].id
|
||||
|
||||
User = request.env['res.users'].sudo()
|
||||
imported_count = 0
|
||||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for idx, record in enumerate(sis_data):
|
||||
email = (record.get('email') or '').strip()
|
||||
name = (record.get('name') or '').strip()
|
||||
national_id = (record.get('national_id') or '').strip()
|
||||
phone = (record.get('phone') or '').strip()
|
||||
|
||||
if not email or not name:
|
||||
errors.append({
|
||||
'index': idx,
|
||||
'message': 'name and email are required',
|
||||
})
|
||||
continue
|
||||
|
||||
if not EMAIL_RE.match(email):
|
||||
errors.append({
|
||||
'index': idx,
|
||||
'message': f'Invalid email: {email}',
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
existing = User.search([('login', '=', email)], limit=1)
|
||||
if existing:
|
||||
update_vals = {'name': name}
|
||||
if phone:
|
||||
update_vals['phone'] = phone
|
||||
existing.write(update_vals)
|
||||
if entity_id and hasattr(existing, 'entity_ids'):
|
||||
if entity_id not in existing.entity_ids.ids:
|
||||
existing.write({'entity_ids': [(4, entity_id)]})
|
||||
updated_count += 1
|
||||
else:
|
||||
create_vals = {
|
||||
'name': name,
|
||||
'login': email,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'password': national_id or email.split('@')[0],
|
||||
'account_source': 'entity_bulk_upload',
|
||||
'account_status': 'activated',
|
||||
}
|
||||
if entity_id:
|
||||
create_vals['entity_id'] = entity_id
|
||||
User.create(create_vals)
|
||||
imported_count += 1
|
||||
except Exception as rec_err:
|
||||
errors.append({
|
||||
'index': idx,
|
||||
'message': str(rec_err),
|
||||
})
|
||||
_logger.warning('SIS import error for row %d: %s', idx, rec_err)
|
||||
|
||||
return _json_response({
|
||||
'imported_count': imported_count,
|
||||
'updated_count': updated_count,
|
||||
'errors': errors,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('sis_import failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
@@ -0,0 +1,2 @@
|
||||
from . import csv_parser
|
||||
from . import credential_service
|
||||
@@ -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])
|
||||
@@ -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
|
||||
120
new_project/custom_addons/encoach_exam/models/exam.py
Normal file
120
new_project/custom_addons/encoach_exam/models/exam.py
Normal 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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import controllers
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import exam_session
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +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
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 || "";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 < minWords ? ' ec-word-count--low' : '')
|
||||
+ (state.wordCount >= minWords and state.wordCount <= 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import templates
|
||||
from . import ielts_exam
|
||||
from . import custom_exam
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user