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:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -26,3 +26,12 @@ Thumbs.db
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
*.tar
|
*.tar
|
||||||
|
|
||||||
|
# Frontend repo (separate repository)
|
||||||
|
new_project/encoach_frontend_new_v1/
|
||||||
|
|
||||||
|
# Local dev artifacts
|
||||||
|
miniconda3/
|
||||||
|
pgdata/
|
||||||
|
.conda-envs/
|
||||||
|
.conda-pkgs/
|
||||||
|
|||||||
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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user