6 Commits

Author SHA1 Message Date
Yamen Ahmad
a3e12f62fa feat: complete 42/42 user story coverage with tested demo data
- Registration: add role field (academic_student/self_learner/teacher),
  return user_id, fix auth='public' for Odoo 19, add company_id
- Onboarding: add learning_style as JSON array with validation,
  placement_decision field, JSON-RPC routes for OWL wizard
- Student profile: add placement_decision selection, get/set_learning_styles
  helpers for JSON array storage
- Portal templates: fix course.description, mod.description,
  profile.study_preference, profile.entity_id.entity_type field references

Made-with: Cursor
2026-04-10 00:26:37 +04:00
Yamen Ahmad
5ff9f12de7 feat: complete all 12 missing spec features from EnCoach v3.0 audit
- Add student_progress, course_section models and module_resources M2M
- Activate encoach_resources module with security, views, and ielts_certified field
- Add password strength indicator JS on registration page
- Rewrite onboarding wizard step 4 as placement test prompt with skip option
- Dynamic goal list fetched from exam templates in DB with fallback
- B1 default CEFR level assigned when placement test is skipped
- Teacher review dashboard with 2 API endpoints for AI content approval
- IELTS examiner review interface with review_status, examiner_notes fields
- Content source gate: mandatory review for AI, spot-check for certified
- Resource-to-learning-style matching in adaptive engine
- Daily cron job for teacher no-progress alerts via mail.activity
- Entity student placement redirect on dashboard access
- Fix Odoo 19 compat: search view groups, ir.cron fields, OWL xml templates

Made-with: Cursor
2026-04-08 03:12:00 +04:00
Yamen Ahmad
e30e52e21e feat: add full-screen exam delivery UI with JWT auth fix
- Fix jwt_required decorator to set request.env user context via
  update_env() instead of passing user as kwarg (fixes null student_id
  on attempt creation for auth='none' API routes)
- Build standalone exam session page with timer, question navigator,
  auto-save, and instant score display (no dependency on web.frontend_layout)
- Add exam_delivery.js for client-side exam interaction (MCQ selection,
  section navigation, countdown, autosave, submit & results)
- Generate per-session JWT in controller for API calls from the exam page

Made-with: Cursor
2026-04-07 23:49:45 +04:00
Yamen Ahmad
d98cd55b99 fix: resolve 9 critical bugs from full local testing
- Migrate 5 OWL components from deprecated useService("rpc") to useService("orm") for Odoo 19
- Fix _paginate() signature mismatch across 6 controllers (dual calling convention)
- Fix taxonomy API serializers referencing non-existent fields (icon, difficulty)
- Fix branding controller validate_token() called with wrong arguments
- Add .sudo() to exam template/question model access under auth='none'
- Restore missing encoach_core files (security groups, permissions seed, core models)
- Create encoach_api shared controller utilities module
- Add Entity list/form/search views and menu items
- Add Taxonomy (Subjects/Domains/Topics) views and menu items
- Fix deprecated XML group expand="0" patterns across 15 view files
- Remove stale model references from adaptive __init__.py and access CSV
- Update .gitignore to exclude local dev artifacts

Tested: 97% pass rate (64/66 tests) across UI navigation, CRUD, and API endpoints.
Made-with: Cursor
2026-04-07 03:43:48 +04:00
Yamen Ahmad
6c93c5d600 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
2026-04-07 01:53:06 +04:00
a4b9ab62cf Merge pull request 'test: verify CI/CD pipeline end-to-end' (#1) from feature/test-pipeline into main 2026-03-30 17:44:08 +02:00
273 changed files with 26226 additions and 0 deletions

33
.gitignore vendored
View File

@@ -26,3 +26,36 @@ Thumbs.db
# Docker
*.tar
# Frontend repo (separate repository)
new_project/encoach_frontend_new_v1/
# Local dev artifacts
miniconda3/
pgdata/
.conda-envs/
.conda-pkgs/
# Odoo core source (cloned separately)
odoo/
# Local Odoo config and data
odoo.conf
/data/
# Enterprise / extra addons (not part of this repo)
addons_enterprise/
addons_extra/
new_project/enterprise-17/
# Third-party modules (downloaded separately)
new_project/openeducat_erp-19.0/
new_project/openeducat_erp-19.0.zip
new_project/openeducate_enterprise-17.zip
new_project/encoach_frontend_new_v1-main.zip
# Local tools
new_project/.tools/
# Large binary archives
*.zip

View File

@@ -0,0 +1,156 @@
# Developer Workflow Manual — EnCoach Backend & Frontend
## One-Time Setup (Do This Once)
**Step 1 — Clone the repositories**
```bash
git clone https://git.albousalh.com/devops/encoach_backend_new_v2.git
git clone https://git.albousalh.com/devops/encoach_frontend_new_v2.git
```
**Step 2 — Switch to the working branch**
```bash
cd encoach_backend_new_v2
git checkout full_stack_dev
cd ../encoach_frontend_new_v2
git checkout full_stack_dev
```
**Step 3 — Install the `tea` CLI (to open PRs from terminal)**
```bash
# macOS
brew install tea
# Or download directly:
# https://gitea.com/gitea/tea/releases
# Configure it (run once):
tea login add --name encoach --url https://git.albousalh.com --token YOUR_TOKEN
```
> To get your token: log in at `https://git.albousalh.com` → top-right avatar → Settings → Applications → Generate Token.
---
## Every Day — Starting Work
**Step 4 — Always pull the latest before coding**
```bash
git checkout full_stack_dev
git pull origin full_stack_dev
```
> This makes sure you have the latest code before making changes.
---
## Making Changes
**Step 5 — Make your code changes locally**
Work as normal in your editor. No restrictions on what you change.
**Step 6 — If you add a new Python package**, add it to `new_project/requirements.txt`:
```
openai>=1.50
your-new-package>=x.x
```
**Step 7 — If you add a new Odoo addon**, place it under `new_project/custom_addons/your_module/` and make sure:
- The `__manifest__.py` lists **all** modules you reference via `comodel_name=` in its `depends` list.
- Notify Talal — new addons need a one-time manual install on the server (see end of this doc).
---
## Pushing Your Work
**Step 8 — Stage and commit your changes**
```bash
git add .
git commit -m "Short description of what you changed"
```
**Step 9 — Push to your working branch**
```bash
git push origin full_stack_dev
```
**Step 10 — Open a Pull Request** (asks Talal for review/approval)
```bash
tea pr create \
--repo devops/encoach_backend_new_v2 \
--head full_stack_dev \
--base main \
--title "Brief title of your changes" \
--description "What you changed and why"
```
> For frontend, replace `encoach_backend_new_v2` with `encoach_frontend_new_v2`.
**Step 11 — Notify Talal** that a PR is waiting for his review.
---
## After Talal Approves & Merges
**Step 12 — Deployment is fully automatic.** You do not need to do anything on the server.
What happens behind the scenes:
1. Talal merges the PR on `https://git.albousalh.com`
2. The server detects the merge to `main`
3. The deploy script pulls the latest code
4. Docker rebuilds the image (if `Dockerfile` or `requirements.txt` changed — takes ~1015 min; otherwise ~1 min)
5. The container restarts with the new code
**Step 13 — Verify it worked** by checking the live URL:
- Frontend: `http://5.189.151.117:3000`
- Backend API: `http://5.189.151.117:8069/api/login`
---
## Checking Logs If Something Goes Wrong
SSH into the server (ask Talal for credentials) and run:
```bash
# Backend logs
docker logs backend-v2-odoo --tail 50
# Frontend logs
docker logs encoach-frontend --tail 50
```
---
## Special Case: Adding a New Odoo Addon
New addons need a **one-time manual install** on the server (the database doesn't know about them yet). Tell Talal and he will run:
```bash
ssh root@5.189.151.117
docker stop backend-v2-odoo
docker run --rm \
--network backend-v2_default \
-v /opt/encoach/backend-v2/odoo-docker.conf:/etc/odoo/odoo.conf:ro \
-v /opt/encoach/backend-v2/new_project/custom_addons:/mnt/custom_addons:ro \
-v /opt/encoach/backend-v2/new_project/enterprise-19:/mnt/enterprise:ro \
-v /opt/encoach/backend-v2/new_project/openeducat_erp-19.0:/mnt/openeducat:ro \
-v odoo-web-data:/var/lib/odoo \
encoach-backend:latest \
odoo -c /etc/odoo/odoo.conf -d encoach_v2 -i your_new_module --stop-after-init
docker start backend-v2-odoo
```
> After the first install, all future deploys update it automatically.
---
## Quick Reference
| Action | Command |
|---|---|
| Pull latest | `git pull origin full_stack_dev` |
| Commit | `git add . && git commit -m "message"` |
| Push | `git push origin full_stack_dev` |
| Open PR (backend) | `tea pr create --repo devops/encoach_backend_new_v2 --head full_stack_dev --base main --title "..."` |
| Open PR (frontend) | `tea pr create --repo devops/encoach_frontend_new_v2 --head full_stack_dev --base main --title "..."` |
| View logs (backend) | `docker logs backend-v2-odoo --tail 50` |
| View logs (frontend) | `docker logs encoach-frontend --tail 50` |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,26 @@
{
'name': 'EnCoach Adaptive Learning',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
'author': 'EnCoach',
'depends': ['encoach_core', 'encoach_taxonomy', 'mail'],
'data': [
'security/ir.model.access.csv',
'data/ir_cron.xml',
'views/adaptive_event_views.xml',
'views/adaptive_path_views.xml',
'views/adaptive_settings_views.xml',
'views/signal_timeline_action.xml',
'views/adaptive_menus.xml',
],
'assets': {
'web.assets_backend': [
'encoach_adaptive/static/src/js/signal_timeline.js',
'encoach_adaptive/static/src/xml/signal_timeline.xml',
'encoach_adaptive/static/src/css/signal_timeline.css',
],
},
'installable': True,
'license': 'LGPL-3',
}

View File

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

View File

@@ -0,0 +1,293 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
from odoo.addons.encoach_adaptive.services.style_matcher import STYLE_RESOURCE_MAP
_logger = logging.getLogger(__name__)
class EncoachAdaptiveController(http.Controller):
# ------------------------------------------------------------------
# GET /api/adaptive/dashboard
# ------------------------------------------------------------------
@http.route('/api/adaptive/dashboard', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def dashboard(self, **kw):
try:
Event = request.env['encoach.adaptive.event'].sudo()
Path = request.env['encoach.adaptive.path'].sudo()
from odoo.fields import Datetime as DT
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_students = len(Path.search([]).mapped('student_id'))
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
signals_today = Event.search_count([
('event_type', '=', 'signal'),
('created_at', '>=', today_start),
])
recent_decisions = []
decisions = Event.search(
[('event_type', '=', 'decision')],
limit=10, order='created_at desc',
)
for d in decisions:
recent_decisions.append({
'id': d.id,
'student_id': d.student_id.id,
'student_name': d.student_id.name or '',
'decision': d.decision or '',
'created_at': d.created_at,
})
return _json_response({
'total_students': total_students,
'active_courses': active_courses,
'avg_progress': 0.0,
'signals_today': signals_today,
'recent_decisions': recent_decisions,
})
except Exception as e:
_logger.exception('adaptive dashboard failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/students
# ------------------------------------------------------------------
@http.route('/api/adaptive/students', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def students(self, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Path = request.env['encoach.adaptive.path'].sudo()
Event = request.env['encoach.adaptive.event'].sudo()
paths, total = _paginate(Path, [], page, size, order='id desc')
items = []
for p in paths:
last_signal = Event.search(
[('student_id', '=', p.student_id.id), ('event_type', '=', 'signal')],
limit=1, order='created_at desc',
)
module_queue = []
try:
module_queue = json.loads(p.module_queue or '[]')
except (json.JSONDecodeError, TypeError):
pass
total_modules = len(module_queue) if module_queue else 1
completed = sum(
1 for m in module_queue
if isinstance(m, dict) and m.get('done')
)
progress_pct = round(completed / total_modules * 100, 1) if total_modules else 0.0
current_module = ''
if module_queue and completed < len(module_queue):
entry = module_queue[completed]
if isinstance(entry, dict):
current_module = entry.get('name', '')
elif isinstance(entry, str):
current_module = entry
items.append({
'student_id': p.student_id.id,
'name': p.student_id.name or '',
'course': p.course_id.name if p.course_id else '',
'current_module': current_module,
'progress_pct': progress_pct,
'last_signal': {
'signal_name': last_signal.signal_name or '',
'signal_value': last_signal.signal_value,
'created_at': last_signal.created_at,
} if last_signal else None,
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('adaptive students list failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/student/<int:student_id>/signals
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/signals', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_signals(self, student_id, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Event = request.env['encoach.adaptive.event'].sudo()
domain = [('student_id', '=', student_id)]
events, total = _paginate(Event, domain, page, size, order='created_at desc')
items = []
for ev in events:
items.append({
'id': ev.id,
'event_type': ev.event_type,
'signal_name': ev.signal_name or '',
'signal_value': ev.signal_value,
'decision': ev.decision or '',
'created_at': ev.created_at,
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('student signals failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/student/<int:student_id>/recommended-resources
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/recommended-resources',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def recommended_resources(self, student_id, **kw):
try:
profile = request.env['encoach.student.profile'].sudo().search(
[('user_id', '=', student_id)], limit=1)
learning_style = profile.learning_style if profile else ''
# Get resources for student's current course/module
path = request.env['encoach.adaptive.path'].sudo().search(
[('student_id', '=', student_id)], limit=1, order='id desc')
resources = request.env['encoach.resource'].sudo().search(
[('active', '=', True), ('review_status', '=', 'approved')], limit=50)
if learning_style:
from odoo.addons.encoach_adaptive.services.style_matcher import StyleMatcher
ranked = StyleMatcher.rank_resources(learning_style, resources)
else:
ranked = list(resources)
items = []
for r in ranked[:20]:
items.append({
'id': r.id,
'name': r.name,
'type': r.type or '',
'cefr_level': r.cefr_level or '',
'difficulty': r.difficulty or '',
'duration_minutes': r.duration_minutes,
'style_match': learning_style if r.type in (
STYLE_RESOURCE_MAP.get(learning_style, []) if learning_style else []
) else '',
})
return _json_response({
'student_id': student_id,
'learning_style': learning_style or 'none',
'items': items,
})
except Exception as e:
_logger.exception('recommended_resources failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/settings
# ------------------------------------------------------------------
@http.route('/api/adaptive/settings', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_settings(self, **kw):
try:
user = request.env.user.sudo()
Settings = request.env['encoach.adaptive.settings'].sudo()
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
if not settings:
return _json_response({
'step_up_threshold': 0.85,
'step_down_threshold': 0.50,
'micro_lesson_trigger': 2,
'module_skip_threshold': 0.95,
'no_progress_alert_days': 3,
'max_retries': 3,
'is_default': True,
})
return _json_response({
'id': settings.id,
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
'is_default': False,
})
except Exception as e:
_logger.exception('get adaptive settings failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/adaptive/settings
# ------------------------------------------------------------------
@http.route('/api/adaptive/settings', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_settings(self, **kw):
try:
body = _get_json_body()
user = request.env.user.sudo()
Settings = request.env['encoach.adaptive.settings'].sudo()
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
allowed_fields = [
'step_up_threshold', 'step_down_threshold', 'micro_lesson_trigger',
'module_skip_threshold', 'no_progress_alert_days', 'max_retries',
]
vals = {k: body[k] for k in allowed_fields if k in body}
if not settings:
vals['teacher_id'] = user.id
entity = user.entity_ids[:1]
if entity:
vals['entity_id'] = entity.id
settings = Settings.create(vals)
else:
settings.write(vals)
return _json_response({
'id': settings.id,
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
})
except Exception as e:
_logger.exception('update adaptive settings failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="ir_cron_adaptive_no_progress_alert" model="ir.cron">
<field name="name">EnCoach: Check Student Progress Alerts</field>
<field name="model_id" search="[('model', '=', 'encoach.adaptive.settings')]"/>
<field name="state">code</field>
<field name="code">model._cron_check_no_progress()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,3 @@
from . import adaptive_event
from . import adaptive_path
from . import adaptive_settings

View File

@@ -0,0 +1,19 @@
from odoo import models, fields
class EncoachAdaptiveEvent(models.Model):
_name = 'encoach.adaptive.event'
_description = 'Adaptive Learning Event'
_order = 'created_at desc'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
course_id = fields.Many2one('op.course', ondelete='set null')
event_type = fields.Selection([
('signal', 'Signal'),
('decision', 'Decision'),
], required=True)
signal_name = fields.Char(size=100)
signal_value = fields.Float()
decision = fields.Char(size=200)
context = fields.Text()
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -0,0 +1,16 @@
from odoo import models, fields
class EncoachAdaptivePath(models.Model):
_name = 'encoach.adaptive.path'
_description = 'Adaptive Learning Path'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
course_id = fields.Many2one('op.course', ondelete='set null')
module_queue = fields.Text()
source = fields.Selection([
('placement', 'Placement'),
('exam', 'Exam'),
('ai_generated', 'AI Generated'),
])
next_generation_brief = fields.Text()

View File

@@ -0,0 +1,20 @@
from odoo import api, models, fields
class EncoachAdaptiveSettings(models.Model):
_name = 'encoach.adaptive.settings'
_description = 'Adaptive Engine Settings'
teacher_id = fields.Many2one('res.users', ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='cascade')
step_up_threshold = fields.Float(default=0.85)
step_down_threshold = fields.Float(default=0.50)
micro_lesson_trigger = fields.Integer(default=2)
module_skip_threshold = fields.Float(default=0.95)
no_progress_alert_days = fields.Integer(default=3)
max_retries = fields.Integer(default=3)
@api.model
def _cron_check_no_progress(self):
from odoo.addons.encoach_adaptive.services.alert_service import AdaptiveAlertService
AdaptiveAlertService.check_no_progress(self.env)

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_adaptive_event_user,encoach.adaptive.event.user,model_encoach_adaptive_event,base.group_user,1,1,1,1
access_adaptive_path_user,encoach.adaptive.path.user,model_encoach_adaptive_path,base.group_user,1,1,1,1
access_adaptive_settings_user,encoach.adaptive.settings.user,model_encoach_adaptive_settings,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_adaptive_event_user encoach.adaptive.event.user model_encoach_adaptive_event base.group_user 1 1 1 1
3 access_adaptive_path_user encoach.adaptive.path.user model_encoach_adaptive_path base.group_user 1 1 1 1
4 access_adaptive_settings_user encoach.adaptive.settings.user model_encoach_adaptive_settings base.group_user 1 1 1 1

View File

@@ -0,0 +1,3 @@
from .adaptive_engine import AdaptiveEngine
from . import style_matcher
from . import alert_service

View File

@@ -0,0 +1,149 @@
import logging
import json
_logger = logging.getLogger(__name__)
class AdaptiveEngine:
"""4-phase adaptive learning engine.
Phase 1: Module-level up/down stepping
Phase 2: Micro-lesson injection
Phase 3: Module skipping
Phase 4: No-progress alerts
"""
DEFAULT_SETTINGS = {
'step_up_threshold': 0.85,
'step_down_threshold': 0.50,
'micro_lesson_trigger': 2,
'module_skip_threshold': 0.95,
'no_progress_alert_days': 3,
'max_retries': 3,
}
@staticmethod
def get_settings(env, teacher_id=None, entity_id=None):
"""Get adaptive settings for teacher/entity or defaults."""
Settings = env['encoach.adaptive.settings'].sudo()
settings = None
if teacher_id:
settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1)
if not settings and entity_id:
settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1)
if settings:
return {
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
}
return dict(AdaptiveEngine.DEFAULT_SETTINGS)
@staticmethod
def process_checkpoint(env, student_id, course_id, module_id, score, settings=None):
"""Process a module checkpoint and make adaptive decisions."""
if not settings:
settings = AdaptiveEngine.DEFAULT_SETTINGS
Event = env['encoach.adaptive.event'].sudo()
Module = env['encoach.course.module'].sudo()
module = Module.browse(module_id)
decision = None
signals = []
signals.append({
'signal_name': 'checkpoint_score',
'signal_value': score,
})
if score >= settings['step_up_threshold']:
decision = 'step_up'
module.write({'status': 'completed'})
next_module = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=1, order='sequence')
if next_module:
next_module.write({'status': 'available'})
elif score < settings['step_down_threshold']:
decision = 'step_down'
else:
decision = 'continue'
module.write({'status': 'completed'})
next_module = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=1, order='sequence')
if next_module:
next_module.write({'status': 'available'})
if score >= settings['module_skip_threshold']:
skip_modules = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=2, order='sequence')
for sm in skip_modules:
sm.write({'status': 'skipped'})
if skip_modules:
decision = 'skip_ahead'
signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)})
for sig in signals:
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'signal',
'signal_name': sig['signal_name'],
'signal_value': sig['signal_value'],
})
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'decision',
'decision': decision,
'context': json.dumps({'module_id': module_id, 'score': score}),
})
return {
'decision': decision,
'score': score,
'signals': signals,
}
@staticmethod
def check_no_progress(env, student_id, course_id, settings=None):
"""Phase 4: Check if student has stalled."""
if not settings:
settings = AdaptiveEngine.DEFAULT_SETTINGS
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days'])
Event = env['encoach.adaptive.event'].sudo()
recent_events = Event.search_count([
('student_id', '=', student_id),
('course_id', '=', course_id),
('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')),
])
if recent_events == 0:
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'signal',
'signal_name': 'no_progress_alert',
'signal_value': settings['no_progress_alert_days'],
})
return True
return False

View File

@@ -0,0 +1,67 @@
import logging
from datetime import timedelta
from odoo import fields
_logger = logging.getLogger(__name__)
class AdaptiveAlertService:
"""Checks for students with no learning progress and creates teacher alerts."""
@classmethod
def check_no_progress(cls, env):
"""Find students with no adaptive events within threshold days and alert teachers.
Called by scheduled action (ir.cron).
"""
Settings = env['encoach.adaptive.settings'].sudo()
Event = env['encoach.adaptive.event'].sudo()
Path = env['encoach.adaptive.path'].sudo()
Activity = env['mail.activity'].sudo()
all_settings = Settings.search([])
if not all_settings:
all_settings = Settings.new({'no_progress_alert_days': 3})
for setting in all_settings:
days = setting.no_progress_alert_days or 3
cutoff = fields.Datetime.now() - timedelta(days=days)
teacher = setting.teacher_id
if not teacher:
continue
# Find students with active paths but no recent events
paths = Path.search([])
for path in paths:
student_id = path.student_id.id
recent_events = Event.search_count([
('student_id', '=', student_id),
('created_at', '>=', cutoff),
])
if recent_events == 0:
# Check if alert already exists for this student
existing = Activity.search([
('res_model', '=', 'res.users'),
('res_id', '=', student_id),
('user_id', '=', teacher.id),
('summary', 'ilike', 'No learning progress'),
('date_deadline', '>=', fields.Date.today()),
], limit=1)
if not existing:
activity_type = env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
Activity.create({
'res_model_id': env['ir.model']._get_id('res.users'),
'res_id': student_id,
'user_id': teacher.id,
'activity_type_id': activity_type.id if activity_type else False,
'summary': f'No learning progress for {days}+ days',
'note': f'Student {path.student_id.name} has not shown any '
f'learning activity in the last {days} days.',
'date_deadline': fields.Date.today(),
})
_logger.info('Created no-progress alert for student %s → teacher %s',
path.student_id.name, teacher.name)

View File

@@ -0,0 +1,35 @@
import logging
_logger = logging.getLogger(__name__)
STYLE_RESOURCE_MAP = {
'visual': ['video', 'pdf', 'interactive'],
'auditory': ['video', 'interactive'],
'reading': ['pdf', 'document'],
'kinesthetic': ['interactive'],
}
class StyleMatcher:
"""Ranks learning resources based on student's learning style preference."""
@classmethod
def rank_resources(cls, learning_style, resources):
"""Sort resources so that types matching the student's style come first.
Args:
learning_style: str, one of visual/auditory/reading/kinesthetic
resources: recordset of encoach.resource
Returns:
sorted list of resource records
"""
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
return sorted(resources, key=lambda r: (r.type not in preferred_types, r.id))
@classmethod
def filter_by_style(cls, learning_style, resources):
"""Return only resources matching the learning style (with fallback to all)."""
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
matched = [r for r in resources if r.type in preferred_types]
return matched if matched else list(resources)

View File

@@ -0,0 +1,54 @@
.o_signal_timeline .st-timeline {
position: relative;
padding-left: 48px;
}
.o_signal_timeline .st-timeline::before {
content: "";
position: absolute;
left: 19px;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.o_signal_timeline .st-timeline-item {
position: relative;
margin-bottom: 1rem;
}
.o_signal_timeline .st-timeline-marker {
position: absolute;
left: -48px;
top: 8px;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 0.85rem;
z-index: 1;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.o_signal_timeline .st-timeline-content {
padding-left: 4px;
}
.o_signal_timeline .st-timeline-content .card {
border-radius: 0.5rem;
transition: box-shadow 0.15s;
}
.o_signal_timeline .st-timeline-content .card:hover {
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08) !important;
}
.o_signal_timeline .st-student-card {
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.o_signal_timeline .st-student-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.o_signal_timeline .cursor-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,152 @@
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
class SignalTimeline extends Component {
static template = "encoach_adaptive.SignalTimeline";
static components = { Layout };
static props = ["*"];
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({
loading: true,
studentId: null,
student: null,
events: [],
filteredEvents: [],
filters: { eventType: "", dateFrom: "", dateTo: "" },
totalCount: 0,
});
onWillStart(async () => {
const ctx = this.props.action?.context || {};
this.state.studentId = ctx.student_id || ctx.active_id || null;
if (this.state.studentId) {
await this.loadTimeline();
} else {
await this.loadStudentList();
}
});
}
async loadStudentList() {
try {
this.state.studentList = await this.orm.searchRead(
"res.users",
[["account_source", "!=", false]],
["name", "email"],
{ limit: 50, order: "name" },
);
} catch (e) {
console.error("Failed to load students:", e);
} finally {
this.state.loading = false;
}
}
async selectStudent(studentId) {
this.state.studentId = studentId;
this.state.loading = true;
await this.loadTimeline();
}
async loadTimeline() {
this.state.loading = true;
try {
const domain = [["student_id", "=", this.state.studentId]];
if (this.state.filters.eventType) {
domain.push(["event_type", "=", this.state.filters.eventType]);
}
if (this.state.filters.dateFrom) {
domain.push(["created_at", ">=", this.state.filters.dateFrom]);
}
if (this.state.filters.dateTo) {
domain.push(["created_at", "<=", this.state.filters.dateTo + " 23:59:59"]);
}
const [events, students] = await Promise.all([
this.orm.searchRead(
"encoach.adaptive.event",
domain,
["student_id", "course_id", "event_type", "signal_name", "signal_value", "decision", "context", "created_at"],
{ limit: 100, order: "created_at desc" },
),
this.orm.searchRead(
"res.users",
[["id", "=", this.state.studentId]],
["name", "email"],
),
]);
this.state.student = students.length ? students[0] : null;
this.state.events = events;
this.state.filteredEvents = events;
this.state.totalCount = events.length;
} catch (e) {
console.error("Timeline load error:", e);
} finally {
this.state.loading = false;
}
}
onFilterChange(field, ev) {
this.state.filters[field] = ev.target.value;
if (this.state.studentId) {
this.loadTimeline();
}
}
clearFilters() {
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
if (this.state.studentId) {
this.loadTimeline();
}
}
eventIcon(eventType) {
return eventType === "signal" ? "fa-bolt" : "fa-gavel";
}
eventColor(eventType) {
return eventType === "signal" ? "primary" : "success";
}
formatDate(dt) {
if (!dt) return "";
return dt.replace("T", " ").substring(0, 19);
}
parseContext(ctx) {
if (!ctx) return null;
try {
return typeof ctx === "string" ? JSON.parse(ctx) : ctx;
} catch {
return null;
}
}
get signalCount() {
return this.state.filteredEvents.filter(e => e.event_type === "signal").length;
}
get decisionCount() {
return this.state.filteredEvents.filter(e => e.event_type === "decision").length;
}
goBack() {
this.state.studentId = null;
this.state.student = null;
this.state.events = [];
this.state.filteredEvents = [];
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
this.state.loading = true;
this.loadStudentList();
}
}
registry.category("actions").add("encoach_signal_timeline", SignalTimeline);

View File

@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_adaptive.SignalTimeline">
<Layout display="{ controlPanel: {} }">
<div class="o_signal_timeline">
<div class="container-fluid py-3">
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
<p class="mt-2 text-muted">Loading timeline...</p>
</div>
</t>
<!-- Student Selector -->
<t t-elif="!state.studentId and state.studentList">
<h2 class="mb-3">Adaptive Signal Timeline</h2>
<p class="text-muted mb-4">Select a student to view their adaptive learning events.</p>
<div class="row g-3">
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
<div class="col-lg-3 col-md-4 col-sm-6">
<div class="card shadow-sm h-100 cursor-pointer st-student-card"
t-on-click="() => this.selectStudent(st.id)">
<div class="card-body text-center">
<div class="rounded-circle bg-info bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
<i class="fa fa-user text-info"/>
</div>
<h6 class="card-title mb-0" t-esc="st.name"/>
<small class="text-muted" t-esc="st.email"/>
</div>
</div>
</div>
</t>
</div>
</t>
<!-- Timeline View -->
<t t-elif="state.student">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
t-if="!this.props.action?.context?.student_id">
<i class="fa fa-arrow-left me-1"/> Back
</button>
<div>
<h2 class="mb-0">Adaptive Timeline</h2>
<small class="text-muted">
Student: <strong t-esc="state.student.name"/>
</small>
</div>
</div>
<!-- Filters -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">Event Type</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onFilterChange('eventType', ev)">
<option value="">All Events</option>
<option value="signal">Signals</option>
<option value="decision">Decisions</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">From Date</label>
<input type="date" class="form-control form-control-sm"
t-on-change="(ev) => this.onFilterChange('dateFrom', ev)"/>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">To Date</label>
<input type="date" class="form-control form-control-sm"
t-on-change="(ev) => this.onFilterChange('dateTo', ev)"/>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-sm btn-outline-secondary" t-on-click="clearFilters">
<i class="fa fa-refresh me-1"/> Reset
</button>
</div>
</div>
</div>
</div>
<!-- Summary Badges -->
<div class="d-flex gap-3 mb-4">
<div class="bg-light rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-list me-2 text-secondary"/>
<strong class="me-1" t-esc="state.totalCount"/> events
</div>
<div class="bg-primary bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-bolt me-2 text-primary"/>
<strong class="me-1" t-esc="signalCount"/> signals
</div>
<div class="bg-success bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-gavel me-2 text-success"/>
<strong class="me-1" t-esc="decisionCount"/> decisions
</div>
</div>
<!-- Timeline -->
<div class="st-timeline">
<t t-foreach="state.filteredEvents" t-as="ev" t-key="ev.id">
<div class="st-timeline-item">
<div class="st-timeline-marker"
t-att-class="'bg-' + eventColor(ev.event_type)">
<i class="fa" t-att-class="eventIcon(ev.event_type)"/>
</div>
<div class="st-timeline-content">
<div class="card shadow-sm">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<span class="badge me-1"
t-att-class="'bg-' + eventColor(ev.event_type)"
t-esc="ev.event_type"/>
<strong t-if="ev.signal_name" t-esc="ev.signal_name"/>
<strong t-elif="ev.decision">Decision</strong>
</div>
<small class="text-muted" t-esc="formatDate(ev.created_at)"/>
</div>
<!-- Signal details -->
<div t-if="ev.event_type === 'signal'" class="mt-1">
<span class="text-muted small">Value: </span>
<span class="fw-bold" t-esc="ev.signal_value"/>
<t t-if="ev.course_id">
<span class="text-muted small ms-3">Course: </span>
<span t-esc="ev.course_id[1]"/>
</t>
</div>
<!-- Decision details -->
<div t-if="ev.event_type === 'decision'" class="mt-1">
<span class="small" t-esc="ev.decision"/>
<t t-if="ev.course_id">
<span class="text-muted small ms-3">Course: </span>
<span t-esc="ev.course_id[1]"/>
</t>
</div>
<!-- Context (if any) -->
<div t-if="parseContext(ev.context)" class="mt-1">
<small class="text-muted">
<i class="fa fa-info-circle me-1"/>
<t t-foreach="Object.entries(parseContext(ev.context) || {})" t-as="entry" t-key="entry[0]">
<span class="me-2">
<span class="fw-bold" t-esc="entry[0]"/>: <t t-esc="entry[1]"/>
</span>
</t>
</small>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-if="!state.filteredEvents.length">
<div class="text-center py-5 text-muted">
<i class="fa fa-clock-o fa-3x mb-2 d-block"/>
<p>No adaptive events found for this student.</p>
</div>
</t>
</div>
</t>
<t t-elif="!state.studentId">
<div class="text-center py-5 text-muted">
<i class="fa fa-clock-o fa-3x mb-3 d-block"/>
<p>No student selected. Open from a student record or select one above.</p>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_event_form" model="ir.ui.view">
<field name="name">encoach.adaptive.event.form</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<form string="Adaptive Event">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_id"/>
<field name="event_type"/>
</group>
<group>
<field name="signal_name"/>
<field name="signal_value"/>
<field name="decision"/>
<field name="created_at"/>
</group>
</group>
<group>
<field name="context"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_event_list" model="ir.ui.view">
<field name="name">encoach.adaptive.event.list</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<list string="Adaptive Events">
<field name="student_id"/>
<field name="event_type" widget="badge"/>
<field name="signal_name"/>
<field name="signal_value"/>
<field name="decision"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_adaptive_event_search" model="ir.ui.view">
<field name="name">encoach.adaptive.event.search</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<search string="Search Adaptive Events">
<field name="student_id"/>
<separator/>
<filter string="Signals" name="signal" domain="[('event_type', '=', 'signal')]"/>
<filter string="Decisions" name="decision" domain="[('event_type', '=', 'decision')]"/>
<separator/>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
</search>
</field>
</record>
<record id="action_adaptive_event" model="ir.actions.act_window">
<field name="name">Adaptive Events</field>
<field name="res_model">encoach.adaptive.event</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_adaptive_events"
name="Adaptive Events"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_event"
sequence="60"/>
<menuitem id="menu_adaptive_paths"
name="Adaptive Paths"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_path"
sequence="70"/>
<menuitem id="menu_adaptive_settings"
name="Adaptive Settings"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_settings"
sequence="80"/>
</odoo>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_path_form" model="ir.ui.view">
<field name="name">encoach.adaptive.path.form</field>
<field name="model">encoach.adaptive.path</field>
<field name="arch" type="xml">
<form string="Adaptive Path">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_id"/>
<field name="source"/>
</group>
</group>
<group>
<field name="module_queue"/>
</group>
<group>
<field name="next_generation_brief"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_path_list" model="ir.ui.view">
<field name="name">encoach.adaptive.path.list</field>
<field name="model">encoach.adaptive.path</field>
<field name="arch" type="xml">
<list string="Adaptive Paths">
<field name="student_id"/>
<field name="course_id"/>
<field name="source"/>
</list>
</field>
</record>
<record id="action_adaptive_path" model="ir.actions.act_window">
<field name="name">Adaptive Paths</field>
<field name="res_model">encoach.adaptive.path</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_settings_form" model="ir.ui.view">
<field name="name">encoach.adaptive.settings.form</field>
<field name="model">encoach.adaptive.settings</field>
<field name="arch" type="xml">
<form string="Adaptive Settings">
<sheet>
<group>
<group>
<field name="teacher_id"/>
<field name="entity_id"/>
</group>
<group>
<field name="step_up_threshold"/>
<field name="step_down_threshold"/>
</group>
</group>
<group>
<group>
<field name="micro_lesson_trigger"/>
<field name="module_skip_threshold"/>
</group>
<group>
<field name="no_progress_alert_days"/>
<field name="max_retries"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_settings_list" model="ir.ui.view">
<field name="name">encoach.adaptive.settings.list</field>
<field name="model">encoach.adaptive.settings</field>
<field name="arch" type="xml">
<list string="Adaptive Settings">
<field name="teacher_id"/>
<field name="entity_id"/>
<field name="step_up_threshold"/>
<field name="step_down_threshold"/>
</list>
</field>
</record>
<record id="action_adaptive_settings" model="ir.actions.act_window">
<field name="name">Adaptive Settings</field>
<field name="res_model">encoach.adaptive.settings</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_signal_timeline" model="ir.actions.client">
<field name="name">Signal Timeline</field>
<field name="tag">encoach_signal_timeline</field>
</record>
<menuitem id="menu_signal_timeline"
name="Signal Timeline"
parent="encoach_core.menu_encoach_config"
action="action_signal_timeline"
sequence="55"/>
</odoo>

View File

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

View File

@@ -0,0 +1,20 @@
{
'name': 'EnCoach AI Course',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'AI content generation pipelines for General English and IELTS courses',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen'],
'data': [
'security/ir.model.access.csv',
'views/ai_generation_log_views.xml',
'views/ai_ielts_log_views.xml',
'views/ai_course_menus.xml',
'views/ai_review_views.xml',
'views/ielts_review_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,396 @@
import json
import logging
from odoo import http, fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
class EncoachAiCourseController(http.Controller):
# ------------------------------------------------------------------
# POST /api/ai-course/english/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/english/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_english(self, **kw):
try:
body = _get_json_body()
cefr_level = body.get('cefr_level')
if not cefr_level:
return _error_response('cefr_level is required', 400)
user = request.env.user.sudo()
gap_profile_id = body.get('gap_profile_id')
GapProfile = request.env['encoach.gap.profile'].sudo()
if gap_profile_id:
gap_profile = GapProfile.browse(int(gap_profile_id))
if not gap_profile.exists():
return _error_response('Gap profile not found', 404)
else:
gap_profile = GapProfile.search(
[('student_id', '=', user.id)],
order='created_at desc', limit=1,
)
if not gap_profile:
gap_profile = GapProfile.create({
'student_id': user.id,
'source_type': 'exam',
'skill_gaps': json.dumps([]),
'topic_weaknesses': json.dumps([]),
})
brief = {
'skill_gaps': json.loads(gap_profile.skill_gaps or '[]'),
'topic_weaknesses': json.loads(gap_profile.topic_weaknesses or '[]'),
'cefr_level': cefr_level,
}
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.create({
'student_id': user.id,
'course_type': 'general_english',
'status': 'pending_review',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'brief': brief,
}, 201)
except Exception as e:
_logger.exception('create_english failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/ielts/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_ielts(self, **kw):
try:
body = _get_json_body()
skill = body.get('skill')
target_band = body.get('target_band')
if not skill:
return _error_response('skill is required', 400)
if not target_band:
return _error_response('target_band is required', 400)
valid_skills = ('listening', 'reading', 'writing', 'speaking')
if skill not in valid_skills:
return _error_response(
f'skill must be one of: {", ".join(valid_skills)}', 400,
)
brief = {
'skill': skill,
'target_band': float(target_band),
'custom_instructions': body.get('brief', ''),
}
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
log = Log.create({
'skill': skill,
'status': 'format_check',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'skill': log.skill,
}, 201)
except Exception as e:
_logger.exception('create_ielts failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/quality
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/quality', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def quality(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
return _json_response({
'status': log.status,
'readability_score': 0.0,
'cefr_alignment': log.status in ('quality_check', 'approved'),
'grammar_issues': [],
'attempts': log.attempts,
})
except Exception as e:
_logger.exception('quality check failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/approve
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
if log.status == 'approved':
return _error_response('Already approved', 400)
log.write({
'status': 'approved',
'approved_by': request.env.user.id,
})
return _json_response({'approved': True})
except Exception as e:
_logger.exception('approve failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/reject
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject(self, course_id, **kw):
try:
body = _get_json_body()
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
reason = body.get('reason', '')
can_retry = log.attempts < 3
if can_retry:
log.write({
'status': 'generating',
'attempts': log.attempts + 1,
'error_log': reason or log.error_log,
})
else:
log.write({
'status': 'rejected',
'error_log': reason or log.error_log,
})
return _json_response({
'rejected': True,
'can_retry': can_retry,
})
except Exception as e:
_logger.exception('reject failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/validation
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/validation', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def validation(self, course_id, **kw):
try:
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
ielts_log = IeltsLog.browse(course_id)
if ielts_log.exists():
fmt_result = {}
band_result = {}
try:
fmt_result = json.loads(ielts_log.format_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
try:
band_result = json.loads(ielts_log.band_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
overall_passed = bool(
ielts_log.format_check_result and ielts_log.band_check_result
)
return _json_response({
'type': 'ielts',
'validation_results': {
'format_check_result': fmt_result,
'band_check_result': band_result,
},
'overall_passed': overall_passed,
})
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
validation_results = {
'status': log.status,
'quality_passed': log.status in ('approved', 'pending_review'),
'attempts': log.attempts,
}
return _json_response({
'type': 'english',
'validation_results': validation_results,
'overall_passed': log.status == 'approved',
})
except Exception as e:
_logger.exception('validation check failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/review-queue
# ------------------------------------------------------------------
@http.route('/api/ai-course/review-queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def review_queue(self, **kw):
try:
body = _get_json_body() if request.httprequest.content_length else {}
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Resource = request.env['encoach.resource'].sudo()
domain = [('ai_generated', '=', True), ('review_status', '=', 'pending')]
total = Resource.search_count(domain)
resources = Resource.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
return _json_response({
'total': total,
'page': page,
'size': size,
'items': [r.to_api_dict() for r in resources],
})
except Exception as e:
_logger.exception('review_queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/review/<int:resource_id>
# ------------------------------------------------------------------
@http.route('/api/ai-course/review/<int:resource_id>', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def review_resource(self, resource_id, **kw):
try:
body = _get_json_body()
action = body.get('action')
notes = body.get('notes', '')
if action not in ('approve', 'reject'):
return _error_response('action must be approve or reject', 400)
Resource = request.env['encoach.resource'].sudo()
resource = Resource.browse(resource_id)
if not resource.exists():
return _error_response('Resource not found', 404)
vals = {
'review_status': 'approved' if action == 'approve' else 'rejected',
'approved': action == 'approve',
}
resource.write(vals)
return _json_response({'status': vals['review_status'], 'resource_id': resource_id})
except Exception as e:
_logger.exception('review_resource failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/ielts-review-queue
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts-review-queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def ielts_review_queue(self, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
domain = [('review_status', '=', 'pending_review')]
total = Log.search_count(domain)
logs = Log.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
items = []
for log in logs:
items.append({
'id': log.id,
'skill': log.skill or '',
'band_target': log.band_target if hasattr(log, 'band_target') else 0,
'review_status': log.review_status,
'created_at': log.create_date.isoformat() if log.create_date else '',
})
return _json_response({
'total': total,
'page': page,
'size': size,
'items': items,
})
except Exception as e:
_logger.exception('ielts_review_queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/ielts-review/<int:log_id>
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts-review/<int:log_id>', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def ielts_review(self, log_id, **kw):
try:
body = _get_json_body()
action = body.get('action')
examiner_notes = body.get('examiner_notes', '')
if action not in ('approve', 'reject', 'revise'):
return _error_response('action must be approve, reject, or revise', 400)
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
log = Log.browse(log_id)
if not log.exists():
return _error_response('Log not found', 404)
status_map = {
'approve': 'approved',
'reject': 'rejected',
'revise': 'revision_needed',
}
log.write({
'review_status': status_map[action],
'examiner_id': request.env.user.id,
'examiner_notes': examiner_notes,
'reviewed_at': fields.Datetime.now(),
})
return _json_response({'status': status_map[action], 'log_id': log_id})
except Exception as e:
_logger.exception('ielts_review failed')
return _error_response(str(e), 500)

View File

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

View File

@@ -0,0 +1,25 @@
from odoo import models, fields
class EncoachAiGenerationLog(models.Model):
_name = 'encoach.ai.generation.log'
_description = 'AI Generation Log'
student_id = fields.Many2one('res.users', ondelete='set null')
course_type = fields.Selection([
('general_english', 'General English'),
('ielts', 'IELTS'),
], required=True)
brief = fields.Text()
attempts = fields.Integer(default=0)
final_resource_id = fields.Integer(string='Final Resource ID')
approved_by = fields.Many2one('res.users', ondelete='set null')
status = fields.Selection([
('generating', 'Generating'),
('quality_check', 'Quality Check'),
('pending_review', 'Pending Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='generating', required=True)
created_at = fields.Datetime(default=fields.Datetime.now)
error_log = fields.Text()

View File

@@ -0,0 +1,35 @@
from odoo import models, fields
class EncoachAiIeltsGenerationLog(models.Model):
_name = 'encoach.ai.ielts.generation.log'
_description = 'AI IELTS Generation Log'
skill = fields.Selection([
('listening', 'Listening'),
('reading', 'Reading'),
('writing', 'Writing'),
('speaking', 'Speaking'),
], required=True)
brief = fields.Text()
format_check_result = fields.Text()
band_check_result = fields.Text()
examiner_id = fields.Many2one('res.users', ondelete='set null')
status = fields.Selection([
('generating', 'Generating'),
('format_check', 'Format Check'),
('examiner_review', 'Examiner Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='generating', required=True)
attempts = fields.Integer(default=0)
created_at = fields.Datetime(default=fields.Datetime.now)
review_status = fields.Selection([
('draft', 'Draft'),
('pending_review', 'Pending Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('revision_needed', 'Revision Needed'),
], default='draft')
examiner_notes = fields.Text()
reviewed_at = fields.Datetime()

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 access_encoach_ai_ielts_generation_log_user encoach.ai.ielts.generation.log.user model_encoach_ai_ielts_generation_log base.group_user 1 1 1 1

View File

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

View File

@@ -0,0 +1,124 @@
import json
import logging
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
OpenAIService = None
try:
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
except ImportError:
ContentSourceGate = None
_logger = logging.getLogger(__name__)
class EnglishPipeline:
"""AI content generation pipeline for General English courses."""
def __init__(self, env):
self.env = env
self.ai = OpenAIService(env)
def generate_content(self, env, gap_profile, cefr_level):
"""Generate General English content based on gap analysis.
Args:
env: Odoo environment.
gap_profile: dict with 'skill_gaps' list describing weak areas.
cefr_level: target CEFR level string (e.g. 'B1').
Returns:
dict with generated content.
"""
skill_gaps = gap_profile.get('skill_gaps', [])
gaps_description = ', '.join(skill_gaps) if skill_gaps else 'general skills'
messages = [
{'role': 'system', 'content': (
'You are an expert English language curriculum designer. '
'Return JSON with keys: "title", "objectives", "units" '
'(each unit has "topic", "grammar_focus", "vocabulary", '
'"reading_text", "exercises").'
)},
{'role': 'user', 'content': (
f'Generate a General English course at CEFR {cefr_level} level. '
f'Focus on these gap areas: {gaps_description}. '
f'Include practical, real-world content appropriate for the level.'
)},
]
try:
content = self.ai.chat_json(messages, temperature=0.8)
except Exception as e:
_logger.error("English content generation failed: %s", e)
env['encoach.ai.generation.log'].create({
'course_type': 'general_english',
'brief': json.dumps(gap_profile),
'status': 'rejected',
'error_log': str(e),
})
return {'error': str(e)}
resource = env['encoach.ai.generation.log'].create({
'course_type': 'general_english',
'brief': json.dumps(gap_profile),
'status': 'quality_check',
'attempts': 1,
})
# Apply content source gate logic
if ContentSourceGate is not None:
try:
ContentSourceGate.apply_gate(resource)
except Exception as e:
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
return content
def quality_check(self, env, content, cefr_level):
"""Run quality gate checks on generated content.
Args:
env: Odoo environment.
content: dict of generated content to validate.
cefr_level: target CEFR level for alignment check.
Returns:
dict with 'passed' bool and 'issues' list.
"""
issues = []
if not content.get('units'):
issues.append('No units generated')
else:
for i, unit in enumerate(content['units'], 1):
if not unit.get('exercises'):
issues.append(f'Unit {i} has no exercises')
if not unit.get('reading_text'):
issues.append(f'Unit {i} has no reading text')
if not content.get('objectives'):
issues.append('No learning objectives defined')
messages = [
{'role': 'system', 'content': (
'You are a CEFR alignment expert. Return JSON with keys: '
'"aligned" (bool) and "issues" (list of strings).'
)},
{'role': 'user', 'content': (
f'Check if this content is aligned with CEFR {cefr_level}: '
f'{json.dumps(content)}'
)},
]
try:
alignment = self.ai.chat_json(messages, temperature=0.3)
if not alignment.get('aligned'):
issues.extend(alignment.get('issues', ['CEFR misalignment detected']))
except Exception as e:
_logger.warning("CEFR alignment check failed: %s", e)
issues.append(f'CEFR alignment check error: {e}')
return {'passed': len(issues) == 0, 'issues': issues}

View File

@@ -0,0 +1,170 @@
import json
import logging
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
OpenAIService = None
try:
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
except ImportError:
ContentSourceGate = None
_logger = logging.getLogger(__name__)
SKILL_PROMPTS = {
'listening': (
'Generate an IELTS Listening section. Return JSON with keys: '
'"script", "speakers", "duration_estimate", "questions".'
),
'reading': (
'Generate an IELTS Reading passage with questions. Return JSON with keys: '
'"passage", "word_count", "questions", "question_types".'
),
'writing': (
'Generate an IELTS Writing task. Return JSON with keys: '
'"task_type", "prompt", "requirements", "sample_band_descriptors".'
),
'speaking': (
'Generate an IELTS Speaking part. Return JSON with keys: '
'"part", "questions", "cue_card" (if part 2), "follow_up_questions".'
),
}
IELTS_FORMAT_RULES = {
'listening': {'required_keys': ['script', 'questions'], 'min_questions': 10},
'reading': {'required_keys': ['passage', 'questions'], 'min_word_count': 500},
'writing': {'required_keys': ['prompt', 'requirements']},
'speaking': {'required_keys': ['questions']},
}
class IeltsPipeline:
"""AI content generation pipeline for IELTS skill-specific content."""
def __init__(self, env):
self.env = env
self.ai = OpenAIService(env)
def generate_content(self, env, skill, brief):
"""Generate IELTS skill-specific content.
Args:
env: Odoo environment.
skill: one of 'listening', 'reading', 'writing', 'speaking'.
brief: dict with generation parameters.
Returns:
dict with generated content.
"""
system_prompt = SKILL_PROMPTS.get(skill, SKILL_PROMPTS['reading'])
brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief)
messages = [
{'role': 'system', 'content': (
f'You are an expert IELTS content creator. {system_prompt}'
)},
{'role': 'user', 'content': (
f'Generate IELTS {skill} content based on this brief: {brief_text}'
)},
]
try:
content = self.ai.chat_json(messages, temperature=0.8)
except Exception as e:
_logger.error("IELTS %s content generation failed: %s", skill, e)
return {'error': str(e)}
resource = env['encoach.ai.ielts.generation.log'].create({
'skill': skill,
'brief': brief_text,
'status': 'format_check',
'attempts': 1,
})
# Apply content source gate logic
if ContentSourceGate is not None:
try:
ContentSourceGate.apply_gate(resource)
except Exception as e:
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
return content
def format_check(self, env, content, skill):
"""Validate IELTS format compliance for a given skill.
Args:
env: Odoo environment.
content: dict of generated content.
skill: IELTS skill type.
Returns:
dict with 'passed' bool and 'errors' list.
"""
errors = []
rules = IELTS_FORMAT_RULES.get(skill, {})
for key in rules.get('required_keys', []):
if key not in content or not content[key]:
errors.append(f'Missing required key: {key}')
if skill == 'listening':
questions = content.get('questions', [])
min_q = rules.get('min_questions', 10)
if len(questions) < min_q:
errors.append(
f'Listening requires at least {min_q} questions, '
f'got {len(questions)}'
)
if skill == 'reading':
passage = content.get('passage', '')
min_wc = rules.get('min_word_count', 500)
word_count = len(passage.split()) if passage else 0
if word_count < min_wc:
errors.append(
f'Reading passage must be at least {min_wc} words, '
f'got {word_count}'
)
if skill == 'speaking' and not content.get('questions'):
errors.append('Speaking section requires at least one question')
return {'passed': len(errors) == 0, 'errors': errors}
def band_calibration(self, env, content, target_band):
"""Check content aligns with target IELTS band level.
Args:
env: Odoo environment.
content: dict of generated content.
target_band: float target band score (e.g. 6.5).
Returns:
dict with 'passed' bool and 'issues' list.
"""
messages = [
{'role': 'system', 'content': (
'You are an IELTS band calibration expert. Evaluate whether '
'the provided content matches the target band level. '
'Return JSON with keys: "calibrated" (bool), "estimated_band" (float), '
'"issues" (list of strings).'
)},
{'role': 'user', 'content': (
f'Target band: {target_band}. '
f'Content: {json.dumps(content)}'
)},
]
try:
result = self.ai.chat_json(messages, temperature=0.3)
except Exception as e:
_logger.error("Band calibration failed: %s", e)
return {'passed': False, 'issues': [f'Band calibration error: {e}']}
return {
'passed': result.get('calibrated', False),
'issues': result.get('issues', []),
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_ai_generation_logs"
name="AI Generation Logs"
parent="encoach_core.menu_encoach_courses"
action="encoach_ai_course.action_ai_generation_log"
sequence="30"/>
<menuitem id="menu_ai_ielts_logs"
name="IELTS Generation Logs"
parent="encoach_core.menu_encoach_courses"
action="encoach_ai_course.action_ai_ielts_log"
sequence="40"/>
</odoo>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ai_generation_log_form" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.form</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<form string="AI Generation Log">
<header>
<field name="status" widget="statusbar" statusbar_visible="generating,quality_check,pending_review,approved"/>
</header>
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_type"/>
<field name="attempts"/>
</group>
<group>
<field name="final_resource_id"/>
<field name="approved_by"/>
<field name="created_at"/>
</group>
</group>
<notebook>
<page string="Brief" name="brief">
<field name="brief"/>
</page>
<page string="Error Log" name="error_log">
<field name="error_log"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_ai_generation_log_list" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.list</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<list string="AI Generation Logs">
<field name="course_type"/>
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'pending_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
<field name="attempts"/>
<field name="student_id"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_ai_generation_log_search" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.search</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<search string="Search AI Generation Logs">
<field name="student_id"/>
<separator/>
<filter string="Generating" name="generating" domain="[('status', '=', 'generating')]"/>
<filter string="Quality Check" name="quality_check" domain="[('status', '=', 'quality_check')]"/>
<filter string="Pending Review" name="pending_review" domain="[('status', '=', 'pending_review')]"/>
<filter string="Approved" name="approved" domain="[('status', '=', 'approved')]"/>
<filter string="Rejected" name="rejected" domain="[('status', '=', 'rejected')]"/>
<separator/>
<filter string="General English" name="general_english" domain="[('course_type', '=', 'general_english')]"/>
<filter string="IELTS" name="ielts" domain="[('course_type', '=', 'ielts')]"/>
</search>
</field>
</record>
<record id="action_ai_generation_log" model="ir.actions.act_window">
<field name="name">AI Generation Logs</field>
<field name="res_model">encoach.ai.generation.log</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ai_ielts_log_form" model="ir.ui.view">
<field name="name">encoach.ai.ielts.generation.log.form</field>
<field name="model">encoach.ai.ielts.generation.log</field>
<field name="arch" type="xml">
<form string="IELTS Generation Log">
<header>
<field name="status" widget="statusbar" statusbar_visible="generating,format_check,examiner_review,approved"/>
</header>
<sheet>
<group>
<group>
<field name="skill"/>
<field name="attempts"/>
<field name="examiner_id"/>
</group>
<group>
<field name="created_at"/>
</group>
</group>
<notebook>
<page string="Brief" name="brief">
<field name="brief"/>
</page>
<page string="Format Check Result" name="format_check">
<field name="format_check_result"/>
</page>
<page string="Band Check Result" name="band_check">
<field name="band_check_result"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_ai_ielts_log_list" model="ir.ui.view">
<field name="name">encoach.ai.ielts.generation.log.list</field>
<field name="model">encoach.ai.ielts.generation.log</field>
<field name="arch" type="xml">
<list string="IELTS Generation Logs">
<field name="skill"/>
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'examiner_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
<field name="attempts"/>
<field name="examiner_id"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="action_ai_ielts_log" model="ir.actions.act_window">
<field name="name">IELTS Generation Logs</field>
<field name="res_model">encoach.ai.ielts.generation.log</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_ai_review_queue_list" model="ir.ui.view">
<field name="name">encoach.resource.ai.review.list</field>
<field name="model">encoach.resource</field>
<field name="arch" type="xml">
<list string="AI Content Review Queue">
<field name="name"/>
<field name="type"/>
<field name="cefr_level"/>
<field name="grammar_topic"/>
<field name="review_status" widget="badge" decoration-info="review_status == 'pending'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'"/>
<field name="create_date"/>
</list>
</field>
</record>
<record id="action_ai_review_queue" model="ir.actions.act_window">
<field name="name">AI Content Review</field>
<field name="res_model">encoach.resource</field>
<field name="view_mode">list,form</field>
<field name="domain">[('ai_generated', '=', True)]</field>
<field name="context">{'search_default_pending_review': 1}</field>
</record>
</odoo>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_ielts_review_form" model="ir.ui.view">
<field name="name">encoach.ai.ielts.generation.log.review.form</field>
<field name="model">encoach.ai.ielts.generation.log</field>
<field name="arch" type="xml">
<form string="IELTS Content Review">
<header>
<field name="review_status" widget="statusbar" statusbar_visible="draft,pending_review,approved"/>
</header>
<sheet>
<group>
<group>
<field name="skill" readonly="1"/>
<field name="review_status"/>
</group>
<group>
<field name="examiner_id"/>
<field name="reviewed_at"/>
</group>
</group>
<group string="Examiner Notes">
<field name="examiner_notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_ielts_review_list" model="ir.ui.view">
<field name="name">encoach.ai.ielts.generation.log.review.list</field>
<field name="model">encoach.ai.ielts.generation.log</field>
<field name="arch" type="xml">
<list string="IELTS Content Review Queue">
<field name="skill"/>
<field name="review_status" widget="badge" decoration-info="review_status == 'pending_review'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'" decoration-warning="review_status == 'revision_needed'"/>
<field name="examiner_id"/>
<field name="reviewed_at"/>
<field name="create_date"/>
</list>
</field>
</record>
<record id="action_ielts_review_queue" model="ir.actions.act_window">
<field name="name">IELTS Content Review</field>
<field name="res_model">encoach.ai.ielts.generation.log</field>
<field name="view_mode">list,form</field>
<field name="domain">[('review_status', '\!=', 'draft')]</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,11 @@
{
'name': 'EnCoach API Base',
'version': '19.0.2.0.0',
'category': 'Education',
'summary': 'Base controller utilities (JWT auth, response helpers) for EnCoach REST API',
'author': 'EnCoach',
'depends': ['base', 'encoach_core'],
'data': [],
'installable': True,
'license': 'LGPL-3',
}

View File

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

View File

@@ -0,0 +1,153 @@
import json
import functools
import logging
import time
import jwt as pyjwt
from odoo.http import request
_logger = logging.getLogger(__name__)
_jwt_secret_cache = {"secret": None, "ts": 0}
_JWT_SECRET_TTL = 300
_user_exists_cache = {}
_USER_CACHE_TTL = 60
_USER_CACHE_MAX = 200
def _get_jwt_secret():
now = time.time()
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
return _jwt_secret_cache["secret"]
secret = (
request.env["ir.config_parameter"]
.sudo()
.get_param("encoach.jwt_secret")
)
if secret:
_jwt_secret_cache["secret"] = secret
_jwt_secret_cache["ts"] = now
return secret
def validate_token():
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None."""
auth_header = request.httprequest.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
secret = _get_jwt_secret()
if not secret:
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
return None
try:
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
except pyjwt.ExpiredSignatureError:
return None
except pyjwt.InvalidTokenError:
return None
user_id = payload.get("user_id")
if not user_id:
return None
now = time.time()
cache_key = int(user_id)
cached = _user_exists_cache.get(cache_key)
if cached and (now - cached["ts"]) < _USER_CACHE_TTL:
return request.env["res.users"].sudo().browse(cache_key)
user = request.env["res.users"].sudo().browse(cache_key)
if not user.exists():
return None
_user_exists_cache[cache_key] = {"ts": now}
if len(_user_exists_cache) > _USER_CACHE_MAX:
oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"])
del _user_exists_cache[oldest_key]
return user
def jwt_required(func):
"""Decorator that validates the JWT token and sets request.env user context."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
user = validate_token()
if not user:
return _error_response("Authentication required", status=401)
request.update_env(user=user.id)
return func(*args, **kwargs)
return wrapper
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
def _error_response(message, status=400, code=None):
body = {"error": message}
if code:
body["code"] = code
return request.make_json_response(body, status=status)
def _get_json_body():
try:
return json.loads(request.httprequest.get_data(as_text=True))
except (ValueError, TypeError):
return {}
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
"""Paginate an Odoo model search or extract params from a kwargs dict.
Two calling conventions:
_paginate(Model, domain, page, size, order) → (recordset, total_count)
_paginate(kwargs_dict) → (offset, limit, page)
"""
if isinstance(model_or_kwargs, dict):
kwargs = model_or_kwargs
limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200)
page_raw = int(kwargs.get("page", 0))
pg = max(page_raw, 0)
offset = pg * limit
return offset, limit, pg
Model = model_or_kwargs
limit = min(max(int(size), 1), 200)
pg = max(int(page), 0)
offset = pg * limit
total = Model.search_count(domain or [])
records = Model.search(domain or [], offset=offset, limit=limit, order=order)
return records, total
class EncoachMixin:
"""Shared authentication and response helpers for all EnCoach API controllers."""
def _get_jwt_secret(self):
return _get_jwt_secret()
def _authenticate(self):
return validate_token()
def _json_response(self, data, status=200):
return _json_response(data, status=status)
def _error_response(self, message, status=400, code=None):
return _error_response(message, status=status, code=code)
def _get_json_body(self):
return _get_json_body()
def _paginate_params(self, kwargs):
return _paginate(kwargs)
def _serialize(self, record):
if hasattr(record, "to_encoach_dict"):
return record.to_encoach_dict()
return {"id": record.id}
def _serialize_list(self, records):
return [self._serialize(r) for r in records]

View File

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

View File

@@ -0,0 +1,15 @@
{
'name': 'EnCoach Branding',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Whitelabeling and custom branding per entity',
'author': 'EnCoach',
'depends': ['encoach_core'],
'data': [
'security/ir.model.access.csv',
'views/branding_views.xml',
'views/branding_menus.xml',
],
'installable': True,
'license': 'LGPL-3',
}

View File

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

View File

@@ -0,0 +1,217 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
validate_token,
)
_logger = logging.getLogger(__name__)
class EncoachBrandingController(http.Controller):
# ------------------------------------------------------------------
# GET /api/entity/<int:entity_id>/level-mapping
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/level-mapping', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_level_mapping(self, entity_id, **kw):
try:
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
Mapping = request.env['encoach.entity.level.mapping'].sudo()
mappings = Mapping.search(
[('entity_id', '=', entity_id)],
order='min_score asc',
)
items = []
for m in mappings:
items.append({
'id': m.id,
'entity_id': m.entity_id.id,
'min_score': m.min_score,
'max_score': m.max_score,
'internal_level_name': m.internal_level_name,
'cefr_equivalent': m.cefr_equivalent or '',
})
return _json_response({'mappings': items})
except Exception as e:
_logger.exception('get_level_mapping failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/entity/<int:entity_id>/level-mapping
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/level-mapping', type='http',
auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_level_mapping(self, entity_id, **kw):
try:
body = _get_json_body()
mappings_data = body.get('mappings', [])
if not mappings_data:
return _error_response('mappings list is required', 400)
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
Mapping = request.env['encoach.entity.level.mapping'].sudo()
Mapping.search([('entity_id', '=', entity_id)]).unlink()
new_mappings = []
for md in mappings_data:
if not md.get('internal_level_name'):
return _error_response('internal_level_name is required for each mapping', 400)
rec = Mapping.create({
'entity_id': entity_id,
'min_score': float(md.get('min_score', 0)),
'max_score': float(md.get('max_score', 0)),
'internal_level_name': md['internal_level_name'],
'cefr_equivalent': md.get('cefr_equivalent') or False,
})
new_mappings.append({
'id': rec.id,
'entity_id': rec.entity_id.id,
'min_score': rec.min_score,
'max_score': rec.max_score,
'internal_level_name': rec.internal_level_name,
'cefr_equivalent': rec.cefr_equivalent or '',
})
return _json_response({'mappings': new_mappings})
except Exception as e:
_logger.exception('update_level_mapping failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/entity/<int:entity_id>/branding
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/branding', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_entity_branding(self, entity_id, **kw):
try:
Branding = request.env['encoach.branding'].sudo()
branding = Branding.search([('entity_id', '=', entity_id)], limit=1)
if not branding:
return _error_response('Branding not found for this entity', 404)
return _json_response(branding.to_api_dict())
except Exception as e:
_logger.exception('get_entity_branding failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/entity/<int:entity_id>/branding
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/branding', type='http',
auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_entity_branding(self, entity_id, **kw):
try:
body = _get_json_body()
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
Branding = request.env['encoach.branding'].sudo()
branding = Branding.search([('entity_id', '=', entity_id)], limit=1)
allowed = [
'primary_color', 'secondary_color', 'background_color',
'app_name', 'login_title', 'login_description',
'white_label_domain', 'custom_css',
]
vals = {k: body[k] for k in allowed if k in body}
if not branding:
vals['entity_id'] = entity_id
branding = Branding.create(vals)
else:
branding.write(vals)
return _json_response(branding.to_api_dict())
except Exception as e:
_logger.exception('update_entity_branding failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/entity/branding (combined public + authenticated)
# GET /api/entity/branding/public
#
# If ?domain=<subdomain> is present → public lookup, no auth needed.
# Otherwise → JWT required, return user entity branding.
# ------------------------------------------------------------------
@http.route(
['/api/entity/branding', '/api/entity/branding/public'],
type='http', auth='none', methods=['GET'], csrf=False,
)
def get_branding(self, **kw):
try:
domain_param = kw.get('domain')
if domain_param:
Branding = request.env(su=True)['encoach.branding']
branding = Branding.search(
[('white_label_domain', '=', domain_param)], limit=1,
)
if not branding:
return _error_response('Branding not found for this domain', 404)
return _json_response({
'app_name': branding.app_name or 'EnCoach',
'primary_color': branding.primary_color or '#4F46E5',
'secondary_color': branding.secondary_color or '#7C3AED',
'background_color': branding.background_color or '#FFFFFF',
'has_logo': bool(branding.logo),
'logo_url': branding.logo_url or '',
'login_title': branding.login_title or '',
'login_description': branding.login_description or '',
'custom_css': branding.custom_css or '',
})
user = validate_token()
if not user:
return _error_response('Missing or invalid Authorization header', 401)
entity = user.entity_ids[:1]
if not entity:
return _error_response('User has no associated entity', 404)
Branding = request.env['encoach.branding'].sudo()
branding = Branding.search([('entity_id', '=', entity.id)], limit=1)
if not branding:
return _json_response({
'entity_id': entity.id,
'entity_name': entity.name,
'app_name': 'EnCoach',
'primary_color': entity.primary_color or '#4F46E5',
'secondary_color': entity.secondary_color or '#7C3AED',
'background_color': entity.background_color or '#FFFFFF',
'has_logo': bool(entity.logo),
'logo_url': entity.logo_url or '',
'login_title': entity.login_title or '',
'login_description': entity.login_description or '',
})
return _json_response(branding.to_api_dict())
except Exception as e:
_logger.exception('get_branding failed')
return _error_response(str(e), 500)

View File

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

View File

@@ -0,0 +1,40 @@
from odoo import models, fields
class EncoachBranding(models.Model):
_name = 'encoach.branding'
_description = 'Entity Branding'
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade')
primary_color = fields.Char(default='#4F46E5')
secondary_color = fields.Char(default='#7C3AED')
background_color = fields.Char(default='#FFFFFF')
logo = fields.Binary(attachment=True)
logo_url = fields.Char(string='Logo URL')
favicon = fields.Binary(attachment=True)
app_name = fields.Char(default='EnCoach')
white_label_domain = fields.Char(string='Subdomain')
login_title = fields.Char(default='Welcome to EnCoach')
login_description = fields.Text()
custom_css = fields.Text()
_entity_unique = models.Constraint('UNIQUE(entity_id)', 'Branding record must be unique per entity.')
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'entity_id': self.entity_id.id,
'entity_name': self.entity_id.name,
'primary_color': self.primary_color,
'secondary_color': self.secondary_color,
'background_color': self.background_color or '#FFFFFF',
'has_logo': bool(self.logo),
'logo_url': self.logo_url or '',
'has_favicon': bool(self.favicon),
'app_name': self.app_name,
'white_label_domain': self.white_label_domain or '',
'login_title': self.login_title or '',
'login_description': self.login_description or '',
'custom_css': self.custom_css or '',
}

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_branding_all,encoach.branding.all,model_encoach_branding,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_branding_all encoach.branding.all model_encoach_branding base.group_user 1 1 1 1

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_branding"
name="White-Label Branding"
parent="encoach_core.menu_encoach_entity"
action="encoach_branding.action_branding"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_branding_form" model="ir.ui.view">
<field name="name">encoach.branding.form</field>
<field name="model">encoach.branding</field>
<field name="arch" type="xml">
<form string="Branding">
<sheet>
<group>
<group>
<field name="entity_id"/>
<field name="app_name"/>
<field name="white_label_domain"/>
</group>
<group>
<field name="logo" widget="image" class="oe_avatar"/>
<field name="logo_url"/>
<field name="favicon" widget="image" class="oe_avatar"/>
</group>
</group>
<group string="Colors">
<group>
<field name="primary_color" widget="color"/>
<field name="secondary_color" widget="color"/>
<field name="background_color" widget="color"/>
</group>
</group>
<group string="Login Page">
<field name="login_title"/>
<field name="login_description"/>
</group>
<group string="Custom CSS">
<field name="custom_css"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_branding_list" model="ir.ui.view">
<field name="name">encoach.branding.list</field>
<field name="model">encoach.branding</field>
<field name="arch" type="xml">
<list string="Branding">
<field name="entity_id"/>
<field name="app_name"/>
<field name="primary_color"/>
<field name="secondary_color"/>
<field name="white_label_domain"/>
</list>
</field>
</record>
<record id="action_branding" model="ir.actions.act_window">
<field name="name">White-Label Branding</field>
<field name="res_model">encoach.branding</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,27 @@
{
'name': 'EnCoach Core',
'version': '19.0.1.1',
'category': 'Education',
'summary': 'Core models for EnCoach: users, entities, roles, permissions',
'author': 'EnCoach',
'depends': ['base', 'mail', 'openeducat_core'],
'data': [
'security/encoach_security.xml',
'security/ir.model.access.csv',
'data/encoach_permissions.xml',
'views/encoach_menus.xml',
'views/encoach_entity_views.xml',
'views/entity_level_mapping_views.xml',
'views/dashboard_action.xml',
],
'assets': {
'web.assets_backend': [
'encoach_core/static/src/js/dashboard.js',
'encoach_core/static/src/xml/dashboard.xml',
'encoach_core/static/src/css/dashboard.css',
],
},
'installable': True,
'application': True,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,468 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="0">
<!-- Global permissions (PermissionType from permissions.ts) -->
<record id="perm_viewCorporate" model="encoach.permission">
<field name="code">viewCorporate</field>
<field name="topic">Manage Corporate</field>
</record>
<record id="perm_editCorporate" model="encoach.permission">
<field name="code">editCorporate</field>
<field name="topic">Manage Corporate</field>
</record>
<record id="perm_deleteCorporate" model="encoach.permission">
<field name="code">deleteCorporate</field>
<field name="topic">Manage Corporate</field>
</record>
<record id="perm_createCodeCorporate" model="encoach.permission">
<field name="code">createCodeCorporate</field>
<field name="topic">Manage Corporate</field>
</record>
<record id="perm_viewAdmin" model="encoach.permission">
<field name="code">viewAdmin</field>
<field name="topic">Manage Admin</field>
</record>
<record id="perm_editAdmin" model="encoach.permission">
<field name="code">editAdmin</field>
<field name="topic">Manage Admin</field>
</record>
<record id="perm_deleteAdmin" model="encoach.permission">
<field name="code">deleteAdmin</field>
<field name="topic">Manage Admin</field>
</record>
<record id="perm_createCodeAdmin" model="encoach.permission">
<field name="code">createCodeAdmin</field>
<field name="topic">Manage Admin</field>
</record>
<record id="perm_viewStudent" model="encoach.permission">
<field name="code">viewStudent</field>
<field name="topic">Manage Student</field>
</record>
<record id="perm_editStudent" model="encoach.permission">
<field name="code">editStudent</field>
<field name="topic">Manage Student</field>
</record>
<record id="perm_deleteStudent" model="encoach.permission">
<field name="code">deleteStudent</field>
<field name="topic">Manage Student</field>
</record>
<record id="perm_createCodeStudent" model="encoach.permission">
<field name="code">createCodeStudent</field>
<field name="topic">Manage Student</field>
</record>
<record id="perm_viewTeacher" model="encoach.permission">
<field name="code">viewTeacher</field>
<field name="topic">Manage Teacher</field>
</record>
<record id="perm_editTeacher" model="encoach.permission">
<field name="code">editTeacher</field>
<field name="topic">Manage Teacher</field>
</record>
<record id="perm_deleteTeacher" model="encoach.permission">
<field name="code">deleteTeacher</field>
<field name="topic">Manage Teacher</field>
</record>
<record id="perm_createCodeTeacher" model="encoach.permission">
<field name="code">createCodeTeacher</field>
<field name="topic">Manage Teacher</field>
</record>
<record id="perm_viewCountryManager" model="encoach.permission">
<field name="code">viewCountryManager</field>
<field name="topic">Manage Country Manager</field>
</record>
<record id="perm_editCountryManager" model="encoach.permission">
<field name="code">editCountryManager</field>
<field name="topic">Manage Country Manager</field>
</record>
<record id="perm_deleteCountryManager" model="encoach.permission">
<field name="code">deleteCountryManager</field>
<field name="topic">Manage Country Manager</field>
</record>
<record id="perm_createCodeCountryManager" model="encoach.permission">
<field name="code">createCodeCountryManager</field>
<field name="topic">Manage Country Manager</field>
</record>
<record id="perm_createReadingExam" model="encoach.permission">
<field name="code">createReadingExam</field>
<field name="topic">Manage Exams</field>
</record>
<record id="perm_createListeningExam" model="encoach.permission">
<field name="code">createListeningExam</field>
<field name="topic">Manage Exams</field>
</record>
<record id="perm_createWritingExam" model="encoach.permission">
<field name="code">createWritingExam</field>
<field name="topic">Manage Exams</field>
</record>
<record id="perm_createSpeakingExam" model="encoach.permission">
<field name="code">createSpeakingExam</field>
<field name="topic">Manage Exams</field>
</record>
<record id="perm_createLevelExam" model="encoach.permission">
<field name="code">createLevelExam</field>
<field name="topic">Manage Exams</field>
</record>
<record id="perm_viewExams" model="encoach.permission">
<field name="code">viewExams</field>
<field name="topic">View Pages</field>
</record>
<record id="perm_viewExercises" model="encoach.permission">
<field name="code">viewExercises</field>
<field name="topic">View Pages</field>
</record>
<record id="perm_viewRecords" model="encoach.permission">
<field name="code">viewRecords</field>
<field name="topic">View Pages</field>
</record>
<record id="perm_viewStats" model="encoach.permission">
<field name="code">viewStats</field>
<field name="topic">View Pages</field>
</record>
<record id="perm_viewTickets" model="encoach.permission">
<field name="code">viewTickets</field>
<field name="topic">View Pages</field>
</record>
<record id="perm_viewPaymentRecords" model="encoach.permission">
<field name="code">viewPaymentRecords</field>
<field name="topic">View Pages</field>
</record>
<record id="perm_viewGroup" model="encoach.permission">
<field name="code">viewGroup</field>
<field name="topic">Manage Group</field>
</record>
<record id="perm_editGroup" model="encoach.permission">
<field name="code">editGroup</field>
<field name="topic">Manage Group</field>
</record>
<record id="perm_deleteGroup" model="encoach.permission">
<field name="code">deleteGroup</field>
<field name="topic">Manage Group</field>
</record>
<record id="perm_createGroup" model="encoach.permission">
<field name="code">createGroup</field>
<field name="topic">Manage Group</field>
</record>
<record id="perm_viewCodes" model="encoach.permission">
<field name="code">viewCodes</field>
<field name="topic">Manage Codes</field>
</record>
<record id="perm_deleteCodes" model="encoach.permission">
<field name="code">deleteCodes</field>
<field name="topic">Manage Codes</field>
</record>
<record id="perm_createCodes" model="encoach.permission">
<field name="code">createCodes</field>
<field name="topic">Manage Codes</field>
</record>
<record id="perm_all" model="encoach.permission">
<field name="code">all</field>
<field name="topic">Others</field>
</record>
<!-- Role-level permissions (RolePermission from entityPermissions.ts) -->
<record id="rperm_view_students" model="encoach.permission">
<field name="code">view_students</field>
<field name="topic">Users</field>
</record>
<record id="rperm_view_teachers" model="encoach.permission">
<field name="code">view_teachers</field>
<field name="topic">Users</field>
</record>
<record id="rperm_view_corporates" model="encoach.permission">
<field name="code">view_corporates</field>
<field name="topic">Users</field>
</record>
<record id="rperm_view_mastercorporates" model="encoach.permission">
<field name="code">view_mastercorporates</field>
<field name="topic">Users</field>
</record>
<record id="rperm_edit_students" model="encoach.permission">
<field name="code">edit_students</field>
<field name="topic">Users</field>
</record>
<record id="rperm_edit_teachers" model="encoach.permission">
<field name="code">edit_teachers</field>
<field name="topic">Users</field>
</record>
<record id="rperm_edit_corporates" model="encoach.permission">
<field name="code">edit_corporates</field>
<field name="topic">Users</field>
</record>
<record id="rperm_edit_mastercorporates" model="encoach.permission">
<field name="code">edit_mastercorporates</field>
<field name="topic">Users</field>
</record>
<record id="rperm_delete_students" model="encoach.permission">
<field name="code">delete_students</field>
<field name="topic">Users</field>
</record>
<record id="rperm_delete_teachers" model="encoach.permission">
<field name="code">delete_teachers</field>
<field name="topic">Users</field>
</record>
<record id="rperm_delete_corporates" model="encoach.permission">
<field name="code">delete_corporates</field>
<field name="topic">Users</field>
</record>
<record id="rperm_delete_mastercorporates" model="encoach.permission">
<field name="code">delete_mastercorporates</field>
<field name="topic">Users</field>
</record>
<record id="rperm_generate_reading" model="encoach.permission">
<field name="code">generate_reading</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_view_reading" model="encoach.permission">
<field name="code">view_reading</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_delete_reading" model="encoach.permission">
<field name="code">delete_reading</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_generate_listening" model="encoach.permission">
<field name="code">generate_listening</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_view_listening" model="encoach.permission">
<field name="code">view_listening</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_delete_listening" model="encoach.permission">
<field name="code">delete_listening</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_generate_writing" model="encoach.permission">
<field name="code">generate_writing</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_view_writing" model="encoach.permission">
<field name="code">view_writing</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_delete_writing" model="encoach.permission">
<field name="code">delete_writing</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_generate_speaking" model="encoach.permission">
<field name="code">generate_speaking</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_view_speaking" model="encoach.permission">
<field name="code">view_speaking</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_delete_speaking" model="encoach.permission">
<field name="code">delete_speaking</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_generate_level" model="encoach.permission">
<field name="code">generate_level</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_view_level" model="encoach.permission">
<field name="code">view_level</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_delete_level" model="encoach.permission">
<field name="code">delete_level</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_view_classrooms" model="encoach.permission">
<field name="code">view_classrooms</field>
<field name="topic">Classrooms</field>
</record>
<record id="rperm_create_classroom" model="encoach.permission">
<field name="code">create_classroom</field>
<field name="topic">Classrooms</field>
</record>
<record id="rperm_rename_classrooms" model="encoach.permission">
<field name="code">rename_classrooms</field>
<field name="topic">Classrooms</field>
</record>
<record id="rperm_add_to_classroom" model="encoach.permission">
<field name="code">add_to_classroom</field>
<field name="topic">Classrooms</field>
</record>
<record id="rperm_remove_from_classroom" model="encoach.permission">
<field name="code">remove_from_classroom</field>
<field name="topic">Classrooms</field>
</record>
<record id="rperm_delete_classroom" model="encoach.permission">
<field name="code">delete_classroom</field>
<field name="topic">Classrooms</field>
</record>
<record id="rperm_view_entities" model="encoach.permission">
<field name="code">view_entities</field>
<field name="topic">Entities</field>
</record>
<record id="rperm_rename_entity" model="encoach.permission">
<field name="code">rename_entity</field>
<field name="topic">Entities</field>
</record>
<record id="rperm_add_to_entity" model="encoach.permission">
<field name="code">add_to_entity</field>
<field name="topic">Entities</field>
</record>
<record id="rperm_remove_from_entity" model="encoach.permission">
<field name="code">remove_from_entity</field>
<field name="topic">Entities</field>
</record>
<record id="rperm_delete_entity" model="encoach.permission">
<field name="code">delete_entity</field>
<field name="topic">Entities</field>
</record>
<record id="rperm_view_entity_roles" model="encoach.permission">
<field name="code">view_entity_roles</field>
<field name="topic">Roles</field>
</record>
<record id="rperm_create_entity_role" model="encoach.permission">
<field name="code">create_entity_role</field>
<field name="topic">Roles</field>
</record>
<record id="rperm_rename_entity_role" model="encoach.permission">
<field name="code">rename_entity_role</field>
<field name="topic">Roles</field>
</record>
<record id="rperm_edit_role_permissions" model="encoach.permission">
<field name="code">edit_role_permissions</field>
<field name="topic">Roles</field>
</record>
<record id="rperm_assign_to_role" model="encoach.permission">
<field name="code">assign_to_role</field>
<field name="topic">Roles</field>
</record>
<record id="rperm_delete_entity_role" model="encoach.permission">
<field name="code">delete_entity_role</field>
<field name="topic">Roles</field>
</record>
<record id="rperm_view_assignments" model="encoach.permission">
<field name="code">view_assignments</field>
<field name="topic">Assignments</field>
</record>
<record id="rperm_create_assignment" model="encoach.permission">
<field name="code">create_assignment</field>
<field name="topic">Assignments</field>
</record>
<record id="rperm_edit_assignment" model="encoach.permission">
<field name="code">edit_assignment</field>
<field name="topic">Assignments</field>
</record>
<record id="rperm_delete_assignment" model="encoach.permission">
<field name="code">delete_assignment</field>
<field name="topic">Assignments</field>
</record>
<record id="rperm_start_assignment" model="encoach.permission">
<field name="code">start_assignment</field>
<field name="topic">Assignments</field>
</record>
<record id="rperm_archive_assignment" model="encoach.permission">
<field name="code">archive_assignment</field>
<field name="topic">Assignments</field>
</record>
<record id="rperm_view_entity_statistics" model="encoach.permission">
<field name="code">view_entity_statistics</field>
<field name="topic">Statistics</field>
</record>
<record id="rperm_create_user" model="encoach.permission">
<field name="code">create_user</field>
<field name="topic">Users</field>
</record>
<record id="rperm_create_user_batch" model="encoach.permission">
<field name="code">create_user_batch</field>
<field name="topic">Users</field>
</record>
<record id="rperm_create_code" model="encoach.permission">
<field name="code">create_code</field>
<field name="topic">Codes</field>
</record>
<record id="rperm_create_code_batch" model="encoach.permission">
<field name="code">create_code_batch</field>
<field name="topic">Codes</field>
</record>
<record id="rperm_view_code_list" model="encoach.permission">
<field name="code">view_code_list</field>
<field name="topic">Codes</field>
</record>
<record id="rperm_delete_code" model="encoach.permission">
<field name="code">delete_code</field>
<field name="topic">Codes</field>
</record>
<record id="rperm_view_statistics" model="encoach.permission">
<field name="code">view_statistics</field>
<field name="topic">Statistics</field>
</record>
<record id="rperm_download_statistics_report" model="encoach.permission">
<field name="code">download_statistics_report</field>
<field name="topic">Statistics</field>
</record>
<record id="rperm_edit_grading_system" model="encoach.permission">
<field name="code">edit_grading_system</field>
<field name="topic">Grading</field>
</record>
<record id="rperm_view_student_performance" model="encoach.permission">
<field name="code">view_student_performance</field>
<field name="topic">Students</field>
</record>
<record id="rperm_upload_classroom" model="encoach.permission">
<field name="code">upload_classroom</field>
<field name="topic">Classrooms</field>
</record>
<record id="rperm_download_user_list" model="encoach.permission">
<field name="code">download_user_list</field>
<field name="topic">Users</field>
</record>
<record id="rperm_view_student_record" model="encoach.permission">
<field name="code">view_student_record</field>
<field name="topic">Students</field>
</record>
<record id="rperm_download_student_record" model="encoach.permission">
<field name="code">download_student_record</field>
<field name="topic">Students</field>
</record>
<record id="rperm_pay_entity" model="encoach.permission">
<field name="code">pay_entity</field>
<field name="topic">Payments</field>
</record>
<record id="rperm_view_payment_record" model="encoach.permission">
<field name="code">view_payment_record</field>
<field name="topic">Payments</field>
</record>
<record id="rperm_view_approval_workflows" model="encoach.permission">
<field name="code">view_approval_workflows</field>
<field name="topic">Workflows</field>
</record>
<record id="rperm_update_exam_privacy" model="encoach.permission">
<field name="code">update_exam_privacy</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_view_workflows" model="encoach.permission">
<field name="code">view_workflows</field>
<field name="topic">Workflows</field>
</record>
<record id="rperm_configure_workflows" model="encoach.permission">
<field name="code">configure_workflows</field>
<field name="topic">Workflows</field>
</record>
<record id="rperm_edit_workflow" model="encoach.permission">
<field name="code">edit_workflow</field>
<field name="topic">Workflows</field>
</record>
<record id="rperm_delete_workflow" model="encoach.permission">
<field name="code">delete_workflow</field>
<field name="topic">Workflows</field>
</record>
<record id="rperm_view_confidential_exams" model="encoach.permission">
<field name="code">view_confidential_exams</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_create_confidential_exams" model="encoach.permission">
<field name="code">create_confidential_exams</field>
<field name="topic">Exams</field>
</record>
<record id="rperm_create_public_exams" model="encoach.permission">
<field name="code">create_public_exams</field>
<field name="topic">Exams</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,8 @@
from . import encoach_user
from . import encoach_entity
from . import role
from . import permission
from . import code
from . import invite
from . import user_entity_rel
from . import entity_level_mapping

View File

@@ -0,0 +1,57 @@
import uuid
from odoo import models, fields, api
class EncoachCode(models.Model):
_name = "encoach.code"
_description = "EnCoach Registration Code"
code = fields.Char(required=True, index=True, default=lambda self: uuid.uuid4().hex[:8].upper())
entity_id = fields.Many2one("encoach.entity", ondelete="set null")
creator_id = fields.Many2one("res.users", required=True)
expiry_date = fields.Datetime()
code_type = fields.Selection(
[("individual", "Individual"), ("corporate", "Corporate")],
default="individual",
)
max_uses = fields.Integer(default=1)
uses = fields.Integer(default=0)
user_type = fields.Selection(
[
("student", "Student"),
("teacher", "Teacher"),
("corporate", "Corporate"),
],
default="student",
)
legacy_id = fields.Char(index=True)
@api.model
def validate_code(self, code_str):
rec = self.search([("code", "=", code_str)], limit=1)
if not rec:
return {"valid": False, "error": "Code not found"}
if rec.expiry_date and rec.expiry_date < fields.Datetime.now():
return {"valid": False, "error": "Code expired"}
if rec.max_uses and rec.uses >= rec.max_uses:
return {"valid": False, "error": "Code fully used"}
return {
"valid": True,
"code": rec.to_encoach_dict(),
}
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"code": self.code,
"entity": self.entity_id.id if self.entity_id else None,
"creator": self.creator_id.id,
"expiryDate": (
self.expiry_date.isoformat() if self.expiry_date else None
),
"type": self.code_type,
"maxUses": self.max_uses,
"uses": self.uses,
"userType": self.user_type,
}

View File

@@ -0,0 +1,62 @@
from odoo import models, fields, api
class EncoachEntity(models.Model):
_name = 'encoach.entity'
_description = 'EnCoach Entity'
_inherit = ['mail.thread']
name = fields.Char(required=True, tracking=True)
code = fields.Char(required=True, tracking=True)
type = fields.Selection([
('corporate', 'Corporate'),
('university', 'University'),
('school', 'School'),
('government', 'Government'),
('freelance', 'Freelance'),
], tracking=True)
logo = fields.Binary(attachment=True)
logo_url = fields.Char(string='Logo URL')
primary_color = fields.Char(default='#4F46E5')
secondary_color = fields.Char(default='#7C3AED')
background_color = fields.Char(default='#FFFFFF')
white_label_domain = fields.Char(string='White Label Domain')
login_title = fields.Char()
login_description = fields.Text()
favicon = fields.Binary(attachment=True)
results_release_mode = fields.Selection([
('auto', 'Auto'),
('manual_approval', 'Manual Approval'),
], default='auto')
active = fields.Boolean(default=True)
user_ids = fields.Many2many('res.users', 'encoach_entity_user_rel', 'entity_id', 'user_id', string='Users')
role_ids = fields.One2many('encoach.role', 'entity_id', string='Roles')
user_count = fields.Integer(compute='_compute_user_count', store=True)
_code_unique = models.Constraint('UNIQUE(code)', 'Entity code must be unique.')
@api.depends('user_ids')
def _compute_user_count(self):
for rec in self:
rec.user_count = len(rec.user_ids)
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'code': self.code,
'type': self.type or '',
'logo': bool(self.logo),
'logo_url': self.logo_url or '',
'primary_color': self.primary_color or '#4F46E5',
'secondary_color': self.secondary_color or '#7C3AED',
'background_color': self.background_color or '#FFFFFF',
'white_label_domain': self.white_label_domain or '',
'login_title': self.login_title or '',
'login_description': self.login_description or '',
'has_favicon': bool(self.favicon),
'results_release_mode': self.results_release_mode or 'auto',
'active': self.active,
'user_count': self.user_count,
}

View File

@@ -0,0 +1,86 @@
from odoo import models, fields, api
USER_TYPE_SELECTION = [
('student', 'Student'),
('teacher', 'Teacher'),
('admin', 'Admin'),
('corporate', 'Corporate'),
('mastercorporate', 'Master Corporate'),
('agent', 'Agent'),
('developer', 'Developer'),
]
class EncoachUser(models.Model):
_inherit = 'res.users'
user_type = fields.Selection(USER_TYPE_SELECTION, string='User Type')
is_verified = fields.Boolean(default=False)
entity_ids = fields.Many2many('encoach.entity', 'encoach_entity_user_rel', 'user_id', 'entity_id', string='Entities')
phone = fields.Char()
birth_date = fields.Date()
gender = fields.Selection([('m', 'Male'), ('f', 'Female'), ('o', 'Other')])
op_student_id = fields.Many2one('op.student', string='OpenEduCat Student')
op_faculty_id = fields.Many2one('op.faculty', string='OpenEduCat Faculty')
encoach_avatar = fields.Binary(attachment=True, string='EnCoach Avatar')
last_login = fields.Datetime()
role_ids = fields.Many2many(
'encoach.role', 'encoach_role_user_rel', 'user_id', 'role_id', string='Roles',
)
permission_direct_ids = fields.Many2many(
'encoach.permission', 'encoach_user_permission_rel', 'user_id', 'permission_id',
string='Direct Permissions',
)
first_login = fields.Boolean(default=True)
account_source = fields.Selection([
('self_registered', 'Self Registered'),
('entity_bulk_upload', 'Entity Bulk Upload'),
])
account_status = fields.Selection([
('unactivated', 'Unactivated'),
('activated', 'Activated'),
('suspended', 'Suspended'),
], default='unactivated')
def get_all_permissions(self):
"""Return the union of direct permissions and permissions from all assigned roles."""
self.ensure_one()
role_permissions = self.role_ids.mapped('permission_ids')
return self.permission_direct_ids | role_permissions
def _api_user_type(self):
"""Frontend routes require a non-empty role; many users have no encoach.user_type set yet."""
self.ensure_one()
if self.user_type:
return self.user_type
if self.op_faculty_id:
return 'teacher'
if self.op_student_id:
return 'student'
if self.has_group('base.group_system'):
return 'admin'
return ''
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'email': self.email or '',
'login': self.login or '',
'user_type': self._api_user_type(),
'is_verified': self.is_verified,
'phone': self.phone or '',
'birth_date': self.birth_date.isoformat() if self.birth_date else None,
'gender': self.gender or '',
'avatar': bool(self.encoach_avatar),
'entities': [
{'id': e.id, 'name': e.name, 'logo': bool(e.logo)}
for e in self.entity_ids
],
'last_login': self.last_login.isoformat() if self.last_login else None,
'first_login': self.first_login,
'account_source': self.account_source or '',
'account_status': self.account_status or 'unactivated',
}

View File

@@ -0,0 +1,15 @@
from odoo import models, fields
class EncoachEntityLevelMapping(models.Model):
_name = 'encoach.entity.level.mapping'
_description = 'Entity Level Mapping'
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade', index=True)
min_score = fields.Float(required=True)
max_score = fields.Float(required=True)
internal_level_name = fields.Char(size=100, required=True)
cefr_equivalent = fields.Selection([
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
])

View File

@@ -0,0 +1,46 @@
from odoo import models, fields
class EncoachInvite(models.Model):
_name = "encoach.invite"
_description = "EnCoach Entity Invite"
from_user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
to_user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
status = fields.Selection(
[
("pending", "Pending"),
("accepted", "Accepted"),
("declined", "Declined"),
],
default="pending",
)
legacy_id = fields.Char(index=True)
def action_accept(self):
self.ensure_one()
self.status = "accepted"
existing = self.env["encoach.user.entity.rel"].search([
("user_id", "=", self.to_user_id.id),
("entity_id", "=", self.entity_id.id),
], limit=1)
if not existing:
self.env["encoach.user.entity.rel"].create({
"user_id": self.to_user_id.id,
"entity_id": self.entity_id.id,
})
def action_decline(self):
self.ensure_one()
self.status = "declined"
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"from": self.from_user_id.id,
"to": self.to_user_id.id,
"entity": self.entity_id.id,
"status": self.status,
}

View File

@@ -0,0 +1,24 @@
from odoo import models, fields
class EncoachPermission(models.Model):
_name = "encoach.permission"
_description = "EnCoach Permission"
code = fields.Char(required=True, index=True)
topic = fields.Char()
legacy_id = fields.Char(index=True)
_code_unique = models.Constraint(
"UNIQUE(code)",
"Permission code must be unique.",
)
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"type": self.code,
"topic": self.topic or "",
"users": [],
}

View File

@@ -0,0 +1,22 @@
from odoo import models, fields
class EncoachRole(models.Model):
_name = "encoach.role"
_description = "EnCoach Role"
name = fields.Char(required=True)
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
permission_ids = fields.Many2many("encoach.permission", string="Permissions")
is_default = fields.Boolean(default=False)
legacy_id = fields.Char(index=True)
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"label": self.name,
"entityID": self.entity_id.id,
"permissions": [p.code for p in self.permission_ids if p.code],
"isDefault": self.is_default,
}

View File

@@ -0,0 +1,15 @@
from odoo import models, fields
class EncoachUserEntityRel(models.Model):
_name = "encoach.user.entity.rel"
_description = "EnCoach User-Entity Membership"
user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
role_id = fields.Many2one("encoach.role", ondelete="set null")
_user_entity_unique = models.Constraint(
"UNIQUE(user_id, entity_id)",
"A user can only have one membership per entity.",
)

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- Security groups matching the 7 user types -->
<record id="group_encoach_student" model="res.groups">
<field name="name">EnCoach Student</field>
</record>
<record id="group_encoach_teacher" model="res.groups">
<field name="name">EnCoach Teacher</field>
</record>
<record id="group_encoach_corporate" model="res.groups">
<field name="name">EnCoach Corporate</field>
</record>
<record id="group_encoach_admin" model="res.groups">
<field name="name">EnCoach Admin</field>
<field name="implied_ids" eval="[(4, ref('group_encoach_teacher'))]"/>
</record>
<record id="group_encoach_developer" model="res.groups">
<field name="name">EnCoach Developer</field>
<field name="implied_ids" eval="[(4, ref('group_encoach_admin'))]"/>
</record>
<record id="group_encoach_agent" model="res.groups">
<field name="name">EnCoach Agent</field>
</record>
<record id="group_encoach_mastercorporate" model="res.groups">
<field name="name">EnCoach Master Corporate</field>
<field name="implied_ids" eval="[(4, ref('group_encoach_corporate'))]"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,20 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_entity_student,encoach.entity.student,model_encoach_entity,group_encoach_student,1,0,0,0
access_encoach_entity_teacher,encoach.entity.teacher,model_encoach_entity,group_encoach_teacher,1,1,0,0
access_encoach_entity_admin,encoach.entity.admin,model_encoach_entity,group_encoach_admin,1,1,1,1
access_encoach_role_student,encoach.role.student,model_encoach_role,group_encoach_student,1,0,0,0
access_encoach_role_teacher,encoach.role.teacher,model_encoach_role,group_encoach_teacher,1,1,0,0
access_encoach_role_admin,encoach.role.admin,model_encoach_role,group_encoach_admin,1,1,1,1
access_encoach_permission_student,encoach.permission.student,model_encoach_permission,group_encoach_student,1,0,0,0
access_encoach_permission_teacher,encoach.permission.teacher,model_encoach_permission,group_encoach_teacher,1,0,0,0
access_encoach_permission_admin,encoach.permission.admin,model_encoach_permission,group_encoach_admin,1,1,1,1
access_encoach_invite_student,encoach.invite.student,model_encoach_invite,group_encoach_student,1,0,0,0
access_encoach_invite_teacher,encoach.invite.teacher,model_encoach_invite,group_encoach_teacher,1,1,0,0
access_encoach_invite_admin,encoach.invite.admin,model_encoach_invite,group_encoach_admin,1,1,1,1
access_encoach_code_student,encoach.code.student,model_encoach_code,group_encoach_student,1,0,0,0
access_encoach_code_teacher,encoach.code.teacher,model_encoach_code,group_encoach_teacher,1,1,0,0
access_encoach_code_admin,encoach.code.admin,model_encoach_code,group_encoach_admin,1,1,1,1
access_encoach_user_entity_rel_all,encoach.user.entity.rel.all,model_encoach_user_entity_rel,base.group_user,1,1,1,1
access_encoach_level_mapping_student,encoach.entity.level.mapping.student,model_encoach_entity_level_mapping,group_encoach_student,1,0,0,0
access_encoach_level_mapping_teacher,encoach.entity.level.mapping.teacher,model_encoach_entity_level_mapping,group_encoach_teacher,1,1,0,0
access_encoach_level_mapping_admin,encoach.entity.level.mapping.admin,model_encoach_entity_level_mapping,group_encoach_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_entity_student encoach.entity.student model_encoach_entity group_encoach_student 1 0 0 0
3 access_encoach_entity_teacher encoach.entity.teacher model_encoach_entity group_encoach_teacher 1 1 0 0
4 access_encoach_entity_admin encoach.entity.admin model_encoach_entity group_encoach_admin 1 1 1 1
5 access_encoach_role_student encoach.role.student model_encoach_role group_encoach_student 1 0 0 0
6 access_encoach_role_teacher encoach.role.teacher model_encoach_role group_encoach_teacher 1 1 0 0
7 access_encoach_role_admin encoach.role.admin model_encoach_role group_encoach_admin 1 1 1 1
8 access_encoach_permission_student encoach.permission.student model_encoach_permission group_encoach_student 1 0 0 0
9 access_encoach_permission_teacher encoach.permission.teacher model_encoach_permission group_encoach_teacher 1 0 0 0
10 access_encoach_permission_admin encoach.permission.admin model_encoach_permission group_encoach_admin 1 1 1 1
11 access_encoach_invite_student encoach.invite.student model_encoach_invite group_encoach_student 1 0 0 0
12 access_encoach_invite_teacher encoach.invite.teacher model_encoach_invite group_encoach_teacher 1 1 0 0
13 access_encoach_invite_admin encoach.invite.admin model_encoach_invite group_encoach_admin 1 1 1 1
14 access_encoach_code_student encoach.code.student model_encoach_code group_encoach_student 1 0 0 0
15 access_encoach_code_teacher encoach.code.teacher model_encoach_code group_encoach_teacher 1 1 0 0
16 access_encoach_code_admin encoach.code.admin model_encoach_code group_encoach_admin 1 1 1 1
17 access_encoach_user_entity_rel_all encoach.user.entity.rel.all model_encoach_user_entity_rel base.group_user 1 1 1 1
18 access_encoach_level_mapping_student encoach.entity.level.mapping.student model_encoach_entity_level_mapping group_encoach_student 1 0 0 0
19 access_encoach_level_mapping_teacher encoach.entity.level.mapping.teacher model_encoach_entity_level_mapping group_encoach_teacher 1 1 0 0
20 access_encoach_level_mapping_admin encoach.entity.level.mapping.admin model_encoach_entity_level_mapping group_encoach_admin 1 1 1 1

View File

@@ -0,0 +1,16 @@
.o_encoach_dashboard .card {
transition: transform 0.15s, box-shadow 0.15s;
border-radius: 0.5rem;
}
.o_encoach_dashboard .card.cursor-pointer:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.o_encoach_dashboard h2 {
color: #1f2937;
font-weight: 700;
}
.o_encoach_dashboard .badge {
text-transform: capitalize;
font-size: 0.75rem;
}

View File

@@ -0,0 +1,94 @@
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
class EncoachDashboard extends Component {
static template = "encoach_core.Dashboard";
static components = { Layout };
static props = ["*"];
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({
loading: true,
stats: {
total_students: 0,
total_teachers: 0,
total_courses: 0,
total_exams: 0,
active_sessions: 0,
pending_grading: 0,
pending_approval: 0,
total_entities: 0,
},
recentAttempts: [],
});
onWillStart(async () => {
await this.loadDashboardData();
});
}
async loadDashboardData() {
try {
const [students, teachers, courses, exams, sessions, pendingGrading, pendingApproval, entities] = await Promise.all([
this.orm.searchCount("res.users", [["account_source", "!=", false]]),
this.orm.searchCount("res.users", [["user_type", "=", "teacher"]]),
this.orm.searchCount("op.course", []),
this.orm.searchCount("encoach.exam.custom", []),
this.orm.searchCount("encoach.student.attempt", [["status", "=", "in_progress"]]),
this.orm.searchCount("encoach.student.attempt", [["status", "=", "scoring"]]),
this.orm.searchCount("encoach.student.attempt", [["status", "=", "pending_approval"]]),
this.orm.searchCount("encoach.entity", []),
]);
this.state.stats = {
total_students: students,
total_teachers: teachers,
total_courses: courses,
total_exams: exams,
active_sessions: sessions,
pending_grading: pendingGrading,
pending_approval: pendingApproval,
total_entities: entities,
};
const attempts = await this.orm.searchRead(
"encoach.student.attempt",
[],
["student_id", "exam_id", "status", "overall_band", "started_at"],
{ limit: 10, order: "started_at desc" },
);
this.state.recentAttempts = attempts;
} catch (e) {
console.error("Dashboard load error:", e);
} finally {
this.state.loading = false;
}
}
openAction(model, name, domain = []) {
this.action.doAction({
type: "ir.actions.act_window",
name: name,
res_model: model,
views: [[false, "list"], [false, "form"]],
domain: domain,
});
}
openStudents() { this.openAction("res.users", "Students", [["account_source", "!=", false]]); }
openTeachers() { this.openAction("res.users", "Teachers", [["user_type", "=", "teacher"]]); }
openCourses() { this.openAction("op.course", "Courses"); }
openExams() { this.openAction("encoach.exam.custom", "Exams"); }
openGradingQueue() { this.openAction("encoach.student.attempt", "Grading Queue", [["status", "=", "scoring"]]); }
openPendingApproval() { this.openAction("encoach.student.attempt", "Score Approval", [["status", "=", "pending_approval"]]); }
openEntities() { this.openAction("encoach.entity", "Entities"); }
}
registry.category("actions").add("encoach_dashboard", EncoachDashboard);

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_core.Dashboard">
<Layout display="{ controlPanel: {} }">
<div class="o_encoach_dashboard">
<div class="container-fluid py-4">
<h2 class="mb-4">EnCoach Dashboard</h2>
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x"/>
<p class="mt-2">Loading dashboard...</p>
</div>
</t>
<t t-else="">
<!-- KPI Cards -->
<div class="row g-3 mb-4">
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openStudents">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-primary bg-opacity-10 p-3 me-3">
<i class="fa fa-users fa-2x text-primary"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_students"/>
<small class="text-muted">Students</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openTeachers">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
<i class="fa fa-chalkboard-teacher fa-2x text-success"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_teachers"/>
<small class="text-muted">Teachers</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openCourses">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-info bg-opacity-10 p-3 me-3">
<i class="fa fa-book fa-2x text-info"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_courses"/>
<small class="text-muted">Courses</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openExams">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-warning bg-opacity-10 p-3 me-3">
<i class="fa fa-file-text fa-2x text-warning"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_exams"/>
<small class="text-muted">Exams</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action Cards -->
<div class="row g-3 mb-4">
<div class="col-lg-4 col-md-6">
<div class="card shadow-sm border-start border-danger border-4 cursor-pointer" t-on-click="openGradingQueue">
<div class="card-body">
<h5>Grading Queue</h5>
<h2 class="text-danger" t-esc="state.stats.pending_grading"/>
<small class="text-muted">Exams awaiting grading</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card shadow-sm border-start border-warning border-4 cursor-pointer" t-on-click="openPendingApproval">
<div class="card-body">
<h5>Pending Approval</h5>
<h2 class="text-warning" t-esc="state.stats.pending_approval"/>
<small class="text-muted">Scores awaiting release</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card shadow-sm border-start border-info border-4 cursor-pointer" t-on-click="openEntities">
<div class="card-body">
<h5>Entities</h5>
<h2 class="text-info" t-esc="state.stats.total_entities"/>
<small class="text-muted">Registered organizations</small>
</div>
</div>
</div>
</div>
<!-- Recent Attempts Table -->
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Recent Exam Attempts</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Student</th>
<th>Exam</th>
<th>Status</th>
<th>Band</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.recentAttempts" t-as="attempt" t-key="attempt.id">
<tr>
<td t-esc="attempt.student_id[1]"/>
<td t-esc="attempt.exam_id ? attempt.exam_id[1] : '-'"/>
<td>
<span class="badge"
t-att-class="{
'bg-success': attempt.status === 'released',
'bg-warning': attempt.status === 'pending_approval',
'bg-info': attempt.status === 'scoring',
'bg-secondary': attempt.status === 'in_progress',
'bg-primary': attempt.status === 'scored'
}"
t-esc="attempt.status"/>
</td>
<td t-esc="attempt.overall_band || '-'"/>
<td t-esc="attempt.started_at"/>
</tr>
</t>
<t t-if="!state.recentAttempts.length">
<tr>
<td colspan="5" class="text-center text-muted py-4">No recent attempts</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_encoach_dashboard" model="ir.actions.client">
<field name="name">EnCoach Dashboard</field>
<field name="tag">encoach_dashboard</field>
</record>
<menuitem id="menu_encoach_dashboard_action"
name="Dashboard"
parent="menu_encoach_dashboard"
action="action_encoach_dashboard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_encoach_entity_list" model="ir.ui.view">
<field name="name">encoach.entity.list</field>
<field name="model">encoach.entity</field>
<field name="arch" type="xml">
<list string="Entities">
<field name="name"/>
<field name="code"/>
<field name="type"/>
<field name="white_label_domain"/>
<field name="user_count"/>
<field name="results_release_mode"/>
<field name="active" column_invisible="True"/>
</list>
</field>
</record>
<record id="view_encoach_entity_form" model="ir.ui.view">
<field name="name">encoach.entity.form</field>
<field name="model">encoach.entity</field>
<field name="arch" type="xml">
<form string="Entity">
<sheet>
<div class="oe_button_box" name="button_box">
</div>
<field name="logo" widget="image" class="oe_avatar"/>
<div class="oe_title">
<h1><field name="name" placeholder="Entity Name"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="type"/>
<field name="active"/>
</group>
<group>
<field name="white_label_domain"/>
<field name="results_release_mode"/>
</group>
</group>
<notebook>
<page string="Branding">
<group>
<group>
<field name="primary_color" widget="color"/>
<field name="secondary_color" widget="color"/>
<field name="background_color" widget="color"/>
</group>
<group>
<field name="logo_url"/>
<field name="login_title"/>
<field name="favicon" widget="image"/>
</group>
</group>
<field name="login_description"/>
</page>
<page string="Users">
<field name="user_ids">
<list>
<field name="name"/>
<field name="login"/>
<field name="user_type"/>
</list>
</field>
</page>
<page string="Roles">
<field name="role_ids">
<list>
<field name="name"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_encoach_entity_search" model="ir.ui.view">
<field name="name">encoach.entity.search</field>
<field name="model">encoach.entity</field>
<field name="arch" type="xml">
<search string="Search Entities">
<field name="name"/>
<field name="code"/>
<filter string="Corporate" name="corporate" domain="[('type', '=', 'corporate')]"/>
<filter string="University" name="university" domain="[('type', '=', 'university')]"/>
<filter string="School" name="school" domain="[('type', '=', 'school')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="action_encoach_entity" model="ir.actions.act_window">
<field name="name">Entities</field>
<field name="res_model">encoach.entity</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_encoach_entity_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first entity
</p>
<p>Entities represent organizations (corporates, universities, schools) on the EnCoach platform.</p>
</field>
</record>
<menuitem id="menu_encoach_entity_list"
name="Entities"
parent="menu_encoach_entity"
action="action_encoach_entity"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_encoach_root" name="EnCoach" sequence="10" web_icon="encoach_core,static/description/icon.png"/>
<menuitem id="menu_encoach_dashboard" name="Dashboard" parent="menu_encoach_root" sequence="1"/>
<menuitem id="menu_encoach_exams" name="Exams" parent="menu_encoach_root" sequence="10"/>
<menuitem id="menu_encoach_courses" name="Courses" parent="menu_encoach_root" sequence="20"/>
<menuitem id="menu_encoach_students" name="Students" parent="menu_encoach_root" sequence="30"/>
<menuitem id="menu_encoach_lms" name="LMS" parent="menu_encoach_root" sequence="40"/>
<menuitem id="menu_encoach_content" name="Content" parent="menu_encoach_root" sequence="50"/>
<menuitem id="menu_encoach_entity" name="Entity Management" parent="menu_encoach_root" sequence="60"/>
<menuitem id="menu_encoach_reports" name="Reports" parent="menu_encoach_root" sequence="70"/>
<menuitem id="menu_encoach_config" name="Configuration" parent="menu_encoach_root" sequence="80"/>
<menuitem id="menu_encoach_support" name="Support" parent="menu_encoach_root" sequence="90"/>
</odoo>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_entity_level_mapping_form" model="ir.ui.view">
<field name="name">encoach.entity.level.mapping.form</field>
<field name="model">encoach.entity.level.mapping</field>
<field name="arch" type="xml">
<form string="Entity Level Mapping">
<sheet>
<group>
<group>
<field name="entity_id"/>
<field name="internal_level_name"/>
<field name="cefr_equivalent"/>
</group>
<group>
<field name="min_score"/>
<field name="max_score"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_entity_level_mapping_list" model="ir.ui.view">
<field name="name">encoach.entity.level.mapping.list</field>
<field name="model">encoach.entity.level.mapping</field>
<field name="arch" type="xml">
<list string="Entity Level Mappings" editable="bottom">
<field name="entity_id"/>
<field name="internal_level_name"/>
<field name="min_score"/>
<field name="max_score"/>
<field name="cefr_equivalent"/>
</list>
</field>
</record>
<record id="action_entity_level_mapping" model="ir.actions.act_window">
<field name="name">Entity Level Mappings</field>
<field name="res_model">encoach.entity.level.mapping</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_entity_level_mapping"
name="Entity Level Mappings"
parent="encoach_core.menu_encoach_entity"
action="encoach_core.action_entity_level_mapping"
sequence="10"/>
</odoo>

View File

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

View File

@@ -0,0 +1,27 @@
{
'name': 'EnCoach Course Generation',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Gap analysis, auto course generation, and adaptive progression',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_taxonomy', 'encoach_resources'],
'data': [
'security/ir.model.access.csv',
'views/gap_profile_views.xml',
'views/course_section_views.xml',
'views/course_module_views.xml',
'views/gap_analysis_action.xml',
'views/course_gen_menus.xml',
],
'assets': {
'web.assets_backend': [
'encoach_course_gen/static/src/js/gap_analysis.js',
'encoach_course_gen/static/src/xml/gap_analysis.xml',
'encoach_course_gen/static/src/css/gap_analysis.css',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,630 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
CEFR_ORDER = ['pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2']
STEP_DOWN_THRESHOLD = 0.4
PASS_THRESHOLD_DEFAULT = 0.7
def _cefr_index(level):
try:
return CEFR_ORDER.index(level)
except ValueError:
return 0
def _next_cefr(level):
idx = _cefr_index(level)
return CEFR_ORDER[min(idx + 1, len(CEFR_ORDER) - 1)]
def _module_to_dict(mod):
return {
'id': mod.id,
'name': mod.name,
'cefr_target': mod.cefr_target or '',
'auto_generated': mod.auto_generated,
'completion_criteria': mod.completion_criteria or 'all_resources',
'score_threshold': mod.score_threshold,
'prerequisite_module_id': mod.prerequisite_module_id.id if mod.prerequisite_module_id else None,
'status': mod.status,
'sequence': mod.sequence,
'generation_brief': mod.generation_brief or '',
}
def _course_to_dict(course, include_modules=True):
data = {
'id': course.id,
'name': course.name,
'code': course.code if hasattr(course, 'code') and course.code else '',
'description': course.description if hasattr(course, 'description') else '',
'generation_source': course.generation_source if hasattr(course, 'generation_source') else '',
'progression_model': course.progression_model if hasattr(course, 'progression_model') else 'linear',
'target_band': course.target_band if hasattr(course, 'target_band') else 0.0,
'entity_id': course.entity_id.id if hasattr(course, 'entity_id') and course.entity_id else None,
}
if include_modules:
Module = request.env['encoach.course.module'].sudo()
modules = Module.search([('course_id', '=', course.id)], order='sequence asc')
data['modules'] = [_module_to_dict(m) for m in modules]
return data
class EncoachCourseController(http.Controller):
# ------------------------------------------------------------------
# POST /api/course/create
# ------------------------------------------------------------------
@http.route('/api/course/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
Course = request.env['op.course'].sudo()
course_vals = {
'name': name,
'code': body.get('code', name[:20].upper().replace(' ', '_')),
}
if body.get('subject_id'):
course_vals['subject_id'] = int(body['subject_id'])
if body.get('description') and hasattr(Course, 'description'):
course_vals['description'] = body['description']
if hasattr(Course, 'generation_source'):
course_vals['generation_source'] = 'manual'
if hasattr(Course, 'entity_id'):
user = request.env.user.sudo()
if user.entity_ids:
course_vals['entity_id'] = user.entity_ids[0].id
course = Course.create(course_vals)
Module = request.env['encoach.course.module'].sudo()
modules_data = body.get('modules', [])
for idx, mod_data in enumerate(modules_data):
Module.create({
'name': mod_data.get('name', f'Module {idx + 1}'),
'course_id': course.id,
'cefr_target': mod_data.get('cefr_target', ''),
'completion_criteria': mod_data.get('completion_criteria', 'all_resources'),
'score_threshold': mod_data.get('score_threshold', 0.0),
'sequence': mod_data.get('sequence', (idx + 1) * 10),
'status': 'available' if idx == 0 else 'locked',
})
return _json_response(_course_to_dict(course), 201)
except Exception as e:
_logger.exception('course create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/course/gap-analysis
# ------------------------------------------------------------------
@http.route('/api/course/gap-analysis', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def gap_analysis(self, **kw):
try:
uid = request.env.user.id
GapProfile = request.env['encoach.gap.profile'].sudo()
profile = GapProfile.search([
('student_id', '=', uid),
], limit=1, order='created_at desc')
if not profile:
CatSession = request.env['encoach.cat.session'].sudo()
session = CatSession.search([
('student_id', '=', uid),
('status', '=', 'completed'),
], limit=1, order='completed_at desc')
if not session:
return _error_response('No placement results or gap profile found', 404)
Ability = request.env['encoach.student.ability.model'].sudo()
abilities = Ability.search([('student_id', '=', uid)])
skill_gaps = {}
for ab in abilities:
if ab.theta < 0.5:
skill_gaps[ab.skill] = {
'theta': ab.theta,
'sem': ab.sem,
'gap_severity': 'high' if ab.theta < -0.5 else 'medium' if ab.theta < 0.0 else 'low',
}
Attempt = request.env['encoach.student.attempt'].sudo()
placement_attempt = Attempt.search([
('student_id', '=', uid),
('is_placement', '=', True),
], limit=1, order='id desc')
question_type_weaknesses = {}
topic_weaknesses = {}
if placement_attempt:
Answer = request.env['encoach.student.answer'].sudo()
answers = Answer.search([('attempt_id', '=', placement_attempt.id)])
type_stats = {}
topic_stats = {}
for ans in answers:
q = ans.question_id
qt = q.question_type or 'unknown'
type_stats.setdefault(qt, {'correct': 0, 'total': 0})
type_stats[qt]['total'] += 1
if ans.is_correct:
type_stats[qt]['correct'] += 1
if q.topic_id:
tid = q.topic_id.name or str(q.topic_id.id)
topic_stats.setdefault(tid, {'correct': 0, 'total': 0})
topic_stats[tid]['total'] += 1
if ans.is_correct:
topic_stats[tid]['correct'] += 1
for qt, stats in type_stats.items():
pct = stats['correct'] / stats['total'] if stats['total'] else 0
if pct < 0.6:
question_type_weaknesses[qt] = round(pct, 2)
for topic, stats in topic_stats.items():
pct = stats['correct'] / stats['total'] if stats['total'] else 0
if pct < 0.6:
topic_weaknesses[topic] = round(pct, 2)
user = request.env.user.sudo()
entity_id = user.entity_ids[0].id if user.entity_ids else False
profile = GapProfile.create({
'student_id': uid,
'source_type': 'placement',
'source_id': session.id,
'skill_gaps': json.dumps(skill_gaps),
'question_type_weaknesses': json.dumps(question_type_weaknesses),
'topic_weaknesses': json.dumps(topic_weaknesses),
'entity_id': entity_id,
})
return _json_response({
'gap_profile_id': profile.id,
'student_id': profile.student_id.id,
'source_type': profile.source_type,
'skill_gaps': json.loads(profile.skill_gaps) if profile.skill_gaps else {},
'question_type_weaknesses': json.loads(profile.question_type_weaknesses) if profile.question_type_weaknesses else {},
'topic_weaknesses': json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {},
'created_at': profile.created_at,
})
except Exception as e:
_logger.exception('gap_analysis failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/course/auto-generate
# ------------------------------------------------------------------
@http.route('/api/course/auto-generate', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def auto_generate(self, **kw):
try:
body = _get_json_body()
uid = request.env.user.id
GapProfile = request.env['encoach.gap.profile'].sudo()
gap_profile_id = body.get('gap_profile_id')
if gap_profile_id:
profile = GapProfile.browse(int(gap_profile_id))
if not profile.exists():
return _error_response('Gap profile not found', 404)
else:
profile = GapProfile.search([
('student_id', '=', uid),
], limit=1, order='created_at desc')
if not profile:
return _error_response('No gap profile found. Run gap-analysis first.', 404)
skill_gaps = json.loads(profile.skill_gaps) if profile.skill_gaps else {}
topic_weaknesses = json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {}
Ability = request.env['encoach.student.ability.model'].sudo()
abilities = Ability.search([('student_id', '=', uid)])
ability_map = {}
for ab in abilities:
ability_map[ab.skill] = ab.theta
user = request.env.user.sudo()
entity_id = user.entity_ids[0].id if user.entity_ids else False
Course = request.env['op.course'].sudo()
course_vals = {
'name': f"Personalized Course for {user.name}",
'code': f"AUTO_{uid}_{profile.id}",
}
if hasattr(Course, 'generation_source'):
course_vals['generation_source'] = 'auto_gap'
if hasattr(Course, 'gap_profile_id'):
course_vals['gap_profile_id'] = profile.id
if hasattr(Course, 'entity_id'):
course_vals['entity_id'] = entity_id
course = Course.create(course_vals)
Module = request.env['encoach.course.module'].sudo()
seq = 10
created_modules = []
sorted_gaps = sorted(skill_gaps.items(),
key=lambda x: x[1].get('theta', 0) if isinstance(x[1], dict) else 0)
for skill, gap_info in sorted_gaps:
theta = gap_info.get('theta', 0) if isinstance(gap_info, dict) else 0
if theta < -0.5:
cefr = 'a1'
elif theta < 0.0:
cefr = 'a2'
elif theta < 0.5:
cefr = 'b1'
else:
cefr = 'b2'
severity = gap_info.get('gap_severity', 'medium') if isinstance(gap_info, dict) else 'medium'
mod = Module.create({
'name': f"{skill.title()} Improvement ({severity})",
'course_id': course.id,
'cefr_target': cefr,
'auto_generated': True,
'generation_brief': json.dumps({
'skill': skill,
'theta': theta,
'severity': severity,
}),
'completion_criteria': 'score_threshold',
'score_threshold': PASS_THRESHOLD_DEFAULT,
'sequence': seq,
'status': 'available' if seq == 10 else 'locked',
})
created_modules.append(mod)
seq += 10
for topic_name, accuracy in sorted(topic_weaknesses.items(), key=lambda x: x[1]):
mod = Module.create({
'name': f"Topic Review: {topic_name}",
'course_id': course.id,
'cefr_target': 'b1',
'auto_generated': True,
'generation_brief': json.dumps({
'topic': topic_name,
'accuracy': accuracy,
}),
'completion_criteria': 'all_resources',
'sequence': seq,
'status': 'locked',
})
created_modules.append(mod)
seq += 10
if not created_modules:
Module.create({
'name': 'General Practice',
'course_id': course.id,
'cefr_target': 'b1',
'auto_generated': True,
'completion_criteria': 'all_resources',
'sequence': 10,
'status': 'available',
})
for i in range(1, len(created_modules)):
created_modules[i].write({
'prerequisite_module_id': created_modules[i - 1].id,
})
return _json_response(_course_to_dict(course), 201)
except Exception as e:
_logger.exception('auto_generate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/course/<int:course_id>
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, course_id, **kw):
try:
Course = request.env['op.course'].sudo()
course = Course.browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
data = _course_to_dict(course)
uid = request.env.user.id
Progress = request.env.get('encoach.course.progress')
if Progress is not None:
Progress = Progress.sudo()
for mod in data.get('modules', []):
progress_recs = Progress.search([
('student_id', '=', uid),
('module_id', '=', mod['id']),
])
mod['completed_resources'] = len(
progress_recs.filtered(lambda p: p.completed if hasattr(p, 'completed') else False)
)
return _json_response(data)
except Exception as e:
_logger.exception('course get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/course/<int:course_id>
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, course_id, **kw):
try:
body = _get_json_body()
Course = request.env['op.course'].sudo()
course = Course.browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
allowed_fields = {'name', 'description', 'code'}
vals = {}
for key in allowed_fields:
if key in body and hasattr(course, key):
vals[key] = body[key]
if vals:
course.write(vals)
module_order = body.get('module_order')
if module_order and isinstance(module_order, list):
Module = request.env['encoach.course.module'].sudo()
for idx, mod_id in enumerate(module_order):
mod = Module.browse(int(mod_id))
if mod.exists() and mod.course_id.id == course.id:
mod.write({'sequence': (idx + 1) * 10})
return _json_response(_course_to_dict(course))
except Exception as e:
_logger.exception('course update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/course/<int:course_id>/progress
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>/progress', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def progress(self, course_id, **kw):
try:
body = _get_json_body()
module_id = body.get('module_id')
resource_id = body.get('resource_id')
completed = body.get('completed', False)
if not module_id:
return _error_response('module_id is required', 400)
Module = request.env['encoach.course.module'].sudo()
mod = Module.browse(int(module_id))
if not mod.exists() or mod.course_id.id != course_id:
return _error_response('Module not found in this course', 404)
uid = request.env.user.id
if mod.status == 'locked':
return _error_response('Module is locked', 400)
if mod.status in ('locked', 'available'):
mod.write({'status': 'in_progress'})
Progress = request.env.get('encoach.course.progress')
if Progress is not None and resource_id:
Progress = Progress.sudo()
existing = Progress.search([
('student_id', '=', uid),
('module_id', '=', mod.id),
('resource_id', '=', int(resource_id)),
], limit=1)
if existing:
existing.write({'completed': completed})
else:
Progress.create({
'student_id': uid,
'module_id': mod.id,
'resource_id': int(resource_id),
'completed': completed,
})
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
total_modules = len(all_modules)
completed_modules = len(all_modules.filtered(lambda m: m.status == 'completed'))
module_status = mod.status
if mod.completion_criteria == 'all_resources' and Progress is not None:
Progress = request.env['encoach.course.progress'].sudo()
mod_progress = Progress.search([
('student_id', '=', uid),
('module_id', '=', mod.id),
])
all_done = all(
getattr(p, 'completed', False) for p in mod_progress
) if mod_progress else False
if all_done and mod_progress:
mod.write({'status': 'completed'})
module_status = 'completed'
completed_modules += 1
self._unlock_next_module(mod, all_modules)
overall_pct = round((completed_modules / total_modules) * 100, 1) if total_modules else 0.0
return _json_response({
'module_id': mod.id,
'module_status': module_status,
'overall_progress_pct': overall_pct,
})
except Exception as e:
_logger.exception('progress failed')
return _error_response(str(e), 500)
def _unlock_next_module(self, current_mod, all_modules):
"""Unlock the next sequential module when the current one is completed."""
Module = request.env['encoach.course.module'].sudo()
next_mods = Module.search([
('course_id', '=', current_mod.course_id.id),
('prerequisite_module_id', '=', current_mod.id),
('status', '=', 'locked'),
])
for nm in next_mods:
nm.write({'status': 'available'})
if not next_mods:
found_current = False
for m in all_modules:
if found_current and m.status == 'locked':
m.write({'status': 'available'})
break
if m.id == current_mod.id:
found_current = True
# ------------------------------------------------------------------
# POST /api/course/<int:course_id>/checkpoint
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>/checkpoint', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def checkpoint(self, course_id, **kw):
try:
body = _get_json_body()
module_id = body.get('module_id')
score = body.get('score')
if not module_id or score is None:
return _error_response('module_id and score are required', 400)
score = float(score)
Module = request.env['encoach.course.module'].sudo()
mod = Module.browse(int(module_id))
if not mod.exists() or mod.course_id.id != course_id:
return _error_response('Module not found in this course', 404)
threshold = mod.score_threshold or PASS_THRESHOLD_DEFAULT
passed = score >= threshold
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
next_module_id = None
action = 'continue'
if passed:
mod.write({'status': 'completed'})
self._unlock_next_module(mod, all_modules)
next_mods = Module.search([
('course_id', '=', course_id),
('status', 'in', ['available', 'locked']),
('sequence', '>', mod.sequence),
], limit=1, order='sequence asc')
if next_mods:
next_module_id = next_mods[0].id
action = 'next_module'
else:
action = 'course_complete'
elif score < STEP_DOWN_THRESHOLD:
action = 'remediation'
else:
action = 'retry'
return _json_response({
'module_id': mod.id,
'score': score,
'threshold': threshold,
'passed': passed,
'next_module_id': next_module_id,
'action': action,
})
except Exception as e:
_logger.exception('checkpoint failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/course/<int:course_id>/post-test
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>/post-test', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def post_test(self, course_id, **kw):
try:
Course = request.env['op.course'].sudo()
course = Course.browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
Module = request.env['encoach.course.module'].sudo()
all_modules = Module.search([('course_id', '=', course_id)])
incomplete = all_modules.filtered(lambda m: m.status != 'completed')
if incomplete:
return _error_response(
f'{len(incomplete)} module(s) not completed. Finish all modules first.', 400
)
subject_id = course.subject_id.id if hasattr(course, 'subject_id') and course.subject_id else False
ExamCustom = request.env['encoach.exam.custom'].sudo()
exam = None
if subject_id:
exam = ExamCustom.search([
('subject_id', '=', subject_id),
('status', '=', 'published'),
], limit=1, order='id desc')
if not exam:
exam = ExamCustom.search([
('status', '=', 'published'),
], limit=1, order='id desc')
if not exam:
return _error_response('No published exam found for post-test', 404)
Assignment = request.env['encoach.exam.assignment'].sudo()
uid = request.env.user.id
existing = Assignment.search([
('exam_id', '=', exam.id),
('student_id', '=', uid),
('status', '=', 'assigned'),
], limit=1)
if existing:
return _json_response({'exam_assignment_id': existing.id})
assignment = Assignment.create({
'exam_id': exam.id,
'student_id': uid,
'status': 'assigned',
})
return _json_response({'exam_assignment_id': assignment.id}, 201)
except Exception as e:
_logger.exception('post_test failed')
return _error_response(str(e), 500)

View File

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

View File

@@ -0,0 +1,21 @@
from odoo import models, fields
class OpCourseExtension(models.Model):
_inherit = 'op.course'
generation_source = fields.Selection([
('manual', 'Manual'),
('auto_gap', 'Auto from Gap'),
('ai_english', 'AI English'),
('ai_ielts', 'AI IELTS'),
])
gap_profile_id = fields.Many2one('encoach.gap.profile', ondelete='set null')
progression_model = fields.Selection([
('linear', 'Linear'),
('parallel', 'Parallel'),
('adaptive', 'Adaptive'),
], default='linear')
target_band = fields.Float()
study_hours_week = fields.Integer()
entity_id = fields.Many2one('encoach.entity', ondelete='set null')

View File

@@ -0,0 +1,37 @@
from odoo import models, fields
class EncoachCourseModule(models.Model):
_name = 'encoach.course.module'
_description = 'Course Module'
name = fields.Char(size=200, required=True)
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
cefr_target = fields.Selection([
('pre_a1', 'Pre-A1'),
('a1', 'A1'),
('a2', 'A2'),
('b1', 'B1'),
('b2', 'B2'),
('c1', 'C1'),
('c2', 'C2'),
])
auto_generated = fields.Boolean(default=False)
generation_brief = fields.Text()
completion_criteria = fields.Selection([
('all_resources', 'All Resources'),
('score_threshold', 'Score Threshold'),
('teacher_approval', 'Teacher Approval'),
], default='all_resources')
score_threshold = fields.Float()
prerequisite_module_id = fields.Many2one('encoach.course.module', ondelete='set null')
status = fields.Selection([
('locked', 'Locked'),
('available', 'Available'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('skipped', 'Skipped'),
], default='locked', required=True)
sequence = fields.Integer(default=10)
section_id = fields.Many2one('encoach.course.section', ondelete='set null')
resource_ids = fields.Many2many('encoach.resource', string='Resources')

View File

@@ -0,0 +1,21 @@
from odoo import models, fields
class EncoachCourseSection(models.Model):
_name = 'encoach.course.section'
_description = 'Course Section'
_order = 'sequence'
name = fields.Char(size=200, required=True)
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
skill = fields.Selection([
('listening', 'Listening'),
('reading', 'Reading'),
('writing', 'Writing'),
('speaking', 'Speaking'),
('grammar', 'Grammar'),
('vocabulary', 'Vocabulary'),
('overall', 'Overall'),
])
sequence = fields.Integer(default=10)
module_ids = fields.One2many('encoach.course.module', 'section_id', string='Modules')

View File

@@ -0,0 +1,18 @@
from odoo import models, fields
class EncoachGapProfile(models.Model):
_name = 'encoach.gap.profile'
_description = 'Student Gap Profile'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
source_type = fields.Selection([
('placement', 'Placement'),
('exam', 'Exam'),
], required=True)
source_id = fields.Integer()
skill_gaps = fields.Text()
question_type_weaknesses = fields.Text()
topic_weaknesses = fields.Text()
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_gap_profile_user,encoach.gap.profile.user,model_encoach_gap_profile,base.group_user,1,1,1,1
access_encoach_course_module_user,encoach.course.module.user,model_encoach_course_module,base.group_user,1,1,1,1
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_gap_profile_user encoach.gap.profile.user model_encoach_gap_profile base.group_user 1 1 1 1
3 access_encoach_course_module_user encoach.course.module.user model_encoach_course_module base.group_user 1 1 1 1
4 access_encoach_course_section_user encoach.course.section.user model_encoach_course_section base.group_user 1 1 1 1

View File

@@ -0,0 +1,40 @@
.o_gap_analysis .ga-bar-track {
height: 28px;
background: #e9ecef;
border-radius: 0.5rem;
overflow: hidden;
position: relative;
}
.o_gap_analysis .ga-bar-fill {
height: 100%;
border-radius: 0.5rem;
transition: width 0.6s ease;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
min-width: 0;
}
.o_gap_analysis .ga-bar-label {
color: #fff;
font-size: 0.75rem;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.o_gap_analysis .ga-bar-row {
padding: 4px 0;
}
.o_gap_analysis .ga-student-card {
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.o_gap_analysis .ga-student-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.o_gap_analysis .card {
border-radius: 0.5rem;
}
.o_gap_analysis .cursor-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,162 @@
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
class GapAnalysisVisualization extends Component {
static template = "encoach_course_gen.GapAnalysis";
static components = { Layout };
static props = ["*"];
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({
loading: true,
studentId: null,
student: null,
gapProfile: null,
skillGaps: [],
weakTopics: [],
recommendations: [],
});
onWillStart(async () => {
const ctx = this.props.action?.context || {};
this.state.studentId = ctx.student_id || ctx.active_id || null;
if (this.state.studentId) {
await this.loadGapData();
} else {
await this.loadStudentList();
}
});
}
async loadStudentList() {
try {
this.state.studentList = await this.orm.searchRead(
"res.users",
[["account_source", "!=", false]],
["name", "email", "login"],
{ limit: 50, order: "name" },
);
} catch (e) {
console.error("Failed to load students:", e);
} finally {
this.state.loading = false;
}
}
async selectStudent(studentId) {
this.state.studentId = studentId;
this.state.loading = true;
await this.loadGapData();
}
async loadGapData() {
this.state.loading = true;
try {
const [profiles, students] = await Promise.all([
this.orm.searchRead(
"encoach.gap.profile",
[["student_id", "=", this.state.studentId]],
["student_id", "source_type", "skill_gaps", "question_type_weaknesses", "topic_weaknesses", "created_at"],
{ limit: 1, order: "created_at desc" },
),
this.orm.searchRead(
"res.users",
[["id", "=", this.state.studentId]],
["name", "email"],
),
]);
this.state.student = students.length ? students[0] : null;
this.state.gapProfile = profiles.length ? profiles[0] : null;
if (this.state.gapProfile) {
this._parseGapProfile(this.state.gapProfile);
}
} catch (e) {
console.error("Gap analysis load error:", e);
} finally {
this.state.loading = false;
}
}
_parseGapProfile(profile) {
this.state.skillGaps = this._safeParseJson(profile.skill_gaps, []);
if (!Array.isArray(this.state.skillGaps)) {
const obj = this.state.skillGaps;
this.state.skillGaps = Object.entries(obj).map(([skill, data]) => ({
skill,
current: typeof data === "object" ? (data.current || 0) : data,
target: typeof data === "object" ? (data.target || 100) : 100,
gap: typeof data === "object" ? (data.gap || (data.target || 100) - (data.current || 0)) : (100 - data),
}));
}
const topicRaw = this._safeParseJson(profile.topic_weaknesses, []);
if (Array.isArray(topicRaw)) {
this.state.weakTopics = topicRaw;
} else if (typeof topicRaw === "object") {
this.state.weakTopics = Object.entries(topicRaw).map(([name, score]) => ({
name,
score: typeof score === "number" ? score : 0,
}));
}
this.state.recommendations = this._generateRecommendations();
}
_safeParseJson(val, fallback) {
if (!val) return fallback;
if (typeof val === "object") return val;
try { return JSON.parse(val); } catch { return fallback; }
}
_generateRecommendations() {
const recs = [];
for (const gap of this.state.skillGaps) {
const pct = gap.target > 0 ? (gap.current / gap.target) * 100 : 0;
if (pct < 50) {
recs.push({ icon: "fa-exclamation-triangle", color: "danger", text: `Critical gap in ${gap.skill} — intensive practice recommended` });
} else if (pct < 75) {
recs.push({ icon: "fa-arrow-up", color: "warning", text: `${gap.skill} needs improvement — targeted exercises suggested` });
}
}
if (this.state.weakTopics.length > 0) {
recs.push({ icon: "fa-book", color: "info", text: `Focus on ${this.state.weakTopics.length} weak topic(s) identified in the analysis` });
}
if (recs.length === 0) {
recs.push({ icon: "fa-thumbs-up", color: "success", text: "Student performance is on track across all areas" });
}
return recs;
}
barWidth(current, target) {
if (!target || target <= 0) return 0;
return Math.min(100, Math.round((current / target) * 100));
}
barColor(current, target) {
const pct = target > 0 ? (current / target) * 100 : 0;
if (pct >= 75) return "bg-success";
if (pct >= 50) return "bg-warning";
return "bg-danger";
}
goBack() {
this.state.studentId = null;
this.state.student = null;
this.state.gapProfile = null;
this.state.skillGaps = [];
this.state.weakTopics = [];
this.state.recommendations = [];
this.state.loading = true;
this.loadStudentList();
}
}
registry.category("actions").add("encoach_gap_analysis", GapAnalysisVisualization);

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_course_gen.GapAnalysis">
<Layout display="{ controlPanel: {} }">
<div class="o_gap_analysis">
<div class="container-fluid py-3">
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
<p class="mt-2 text-muted">Analyzing gaps...</p>
</div>
</t>
<!-- Student Selector -->
<t t-elif="!state.studentId and state.studentList">
<h2 class="mb-3">Gap Analysis</h2>
<p class="text-muted mb-4">Select a student to view their skill gap analysis.</p>
<div class="row g-3">
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
<div class="col-lg-3 col-md-4 col-sm-6">
<div class="card shadow-sm h-100 cursor-pointer ga-student-card"
t-on-click="() => this.selectStudent(st.id)">
<div class="card-body text-center">
<div class="rounded-circle bg-primary bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
<i class="fa fa-user text-primary"/>
</div>
<h6 class="card-title mb-0" t-esc="st.name"/>
<small class="text-muted" t-esc="st.email"/>
</div>
</div>
</div>
</t>
</div>
</t>
<!-- Gap Analysis View -->
<t t-elif="state.student">
<div class="d-flex align-items-center mb-4">
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
t-if="!this.props.action?.context?.student_id">
<i class="fa fa-arrow-left me-1"/> Back
</button>
<div>
<h2 class="mb-0">Gap Analysis</h2>
<small class="text-muted">
Student: <strong t-esc="state.student.name"/>
<t t-if="state.gapProfile">
— Source: <span class="badge bg-secondary" t-esc="state.gapProfile.source_type"/>
</t>
</small>
</div>
</div>
<t t-if="!state.gapProfile">
<div class="alert alert-info">
<i class="fa fa-info-circle me-2"/>
No gap profile found for this student. A placement test or exam is required first.
</div>
</t>
<t t-else="">
<!-- Skill Gap Bars -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fa fa-bar-chart me-2 text-primary"/>
Skill Gaps
</h5>
</div>
<div class="card-body">
<t t-if="state.skillGaps.length">
<t t-foreach="state.skillGaps" t-as="gap" t-key="gap.skill">
<div class="ga-bar-row mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-bold text-capitalize" t-esc="gap.skill"/>
<span class="small text-muted">
<t t-esc="gap.current"/> / <t t-esc="gap.target"/>
</span>
</div>
<div class="ga-bar-track">
<div class="ga-bar-fill"
t-att-class="barColor(gap.current, gap.target)"
t-att-style="'width:' + barWidth(gap.current, gap.target) + '%'">
<span class="ga-bar-label" t-if="barWidth(gap.current, gap.target) > 15">
<t t-esc="barWidth(gap.current, gap.target)"/>%
</span>
</div>
</div>
</div>
</t>
</t>
<t t-else="">
<p class="text-muted mb-0">No skill gap data available.</p>
</t>
</div>
</div>
<div class="row g-4">
<!-- Weak Topics -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fa fa-warning me-2 text-warning"/>
Weak Topics
</h5>
</div>
<div class="card-body">
<t t-if="state.weakTopics.length">
<t t-foreach="state.weakTopics" t-as="topic" t-key="topic_index">
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
<span>
<i class="fa fa-circle text-danger me-2" style="font-size:0.5rem; vertical-align:middle"/>
<t t-esc="topic.name || topic"/>
</span>
<span class="badge bg-danger bg-opacity-75" t-if="topic.score !== undefined">
<t t-esc="topic.score"/>%
</span>
</div>
</t>
</t>
<t t-else="">
<p class="text-muted text-center mb-0 py-3">
<i class="fa fa-check-circle text-success me-1"/>
No weak topics identified
</p>
</t>
</div>
</div>
</div>
<!-- Recommendations -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fa fa-lightbulb-o me-2 text-info"/>
Recommended Actions
</h5>
</div>
<div class="card-body">
<t t-foreach="state.recommendations" t-as="rec" t-key="rec_index">
<div class="d-flex align-items-start py-2 border-bottom">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3 flex-shrink-0"
t-att-class="'bg-' + rec.color + ' bg-opacity-10'"
style="width:36px; height:36px">
<i class="fa" t-att-class="rec.icon + ' text-' + rec.color"/>
</div>
<span class="small" t-esc="rec.text"/>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</t>
<t t-elif="!state.studentId">
<div class="text-center py-5 text-muted">
<i class="fa fa-line-chart fa-3x mb-3 d-block"/>
<p>No student selected. Open from a student record or select one above.</p>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_all_courses" model="ir.actions.act_window">
<field name="name">All Courses</field>
<field name="res_model">op.course</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_all_courses"
name="All Courses"
parent="encoach_core.menu_encoach_courses"
action="encoach_course_gen.action_all_courses"
sequence="5"/>
<menuitem id="menu_course_modules"
name="Course Modules"
parent="encoach_core.menu_encoach_courses"
action="encoach_course_gen.action_course_module"
sequence="10"/>
<menuitem id="menu_gap_analysis"
name="Gap Analysis"
parent="encoach_core.menu_encoach_courses"
action="encoach_course_gen.action_gap_profile"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_course_module_form" model="ir.ui.view">
<field name="name">encoach.course.module.form</field>
<field name="model">encoach.course.module</field>
<field name="arch" type="xml">
<form string="Course Module">
<header>
<field name="status" widget="statusbar" statusbar_visible="locked,available,in_progress,completed"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="course_id"/>
<field name="cefr_target"/>
<field name="auto_generated"/>
</group>
<group>
<field name="completion_criteria"/>
<field name="score_threshold" invisible="completion_criteria != 'score_threshold'"/>
<field name="prerequisite_module_id"/>
<field name="sequence"/>
</group>
</group>
<group>
<field name="generation_brief"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_course_module_list" model="ir.ui.view">
<field name="name">encoach.course.module.list</field>
<field name="model">encoach.course.module</field>
<field name="arch" type="xml">
<list string="Course Modules">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="course_id"/>
<field name="cefr_target"/>
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
</list>
</field>
</record>
<record id="view_course_module_search" model="ir.ui.view">
<field name="name">encoach.course.module.search</field>
<field name="model">encoach.course.module</field>
<field name="arch" type="xml">
<search string="Search Course Modules">
<field name="name"/>
<field name="course_id"/>
<separator/>
<filter string="Locked" name="locked" domain="[('status', '=', 'locked')]"/>
<filter string="Available" name="available" domain="[('status', '=', 'available')]"/>
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
<filter string="Skipped" name="skipped" domain="[('status', '=', 'skipped')]"/>
<separator/>
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
</search>
</field>
</record>
<record id="action_course_module" model="ir.actions.act_window">
<field name="name">Course Modules</field>
<field name="res_model">encoach.course.module</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_course_section_form" model="ir.ui.view">
<field name="name">encoach.course.section.form</field>
<field name="model">encoach.course.section</field>
<field name="arch" type="xml">
<form string="Course Section">
<sheet>
<group>
<group>
<field name="name"/>
<field name="course_id"/>
<field name="skill"/>
<field name="sequence"/>
</group>
</group>
<notebook>
<page string="Modules">
<field name="module_ids">
<list>
<field name="name"/>
<field name="cefr_target"/>
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
<field name="sequence"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_encoach_course_section_list" model="ir.ui.view">
<field name="name">encoach.course.section.list</field>
<field name="model">encoach.course.section</field>
<field name="arch" type="xml">
<list string="Course Sections">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="course_id"/>
<field name="skill" widget="badge"/>
</list>
</field>
</record>
<record id="view_encoach_course_section_search" model="ir.ui.view">
<field name="name">encoach.course.section.search</field>
<field name="model">encoach.course.section</field>
<field name="arch" type="xml">
<search string="Search Sections">
<field name="name"/>
<field name="course_id"/>
<separator/>
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
<filter string="Skill" name="group_skill" context="{'group_by': 'skill'}"/>
</search>
</field>
</record>
<record id="action_encoach_course_section" model="ir.actions.act_window">
<field name="name">Course Sections</field>
<field name="res_model">encoach.course.section</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_gap_analysis_viz" model="ir.actions.client">
<field name="name">Gap Analysis</field>
<field name="tag">encoach_gap_analysis</field>
</record>
<menuitem id="menu_gap_analysis_viz"
name="Gap Analysis (Visual)"
parent="encoach_core.menu_encoach_courses"
action="action_gap_analysis_viz"
sequence="25"/>
</odoo>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_gap_profile_form" model="ir.ui.view">
<field name="name">encoach.gap.profile.form</field>
<field name="model">encoach.gap.profile</field>
<field name="arch" type="xml">
<form string="Gap Profile">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="source_type"/>
<field name="source_id"/>
</group>
<group>
<field name="entity_id"/>
<field name="created_at"/>
</group>
</group>
<notebook>
<page string="Skill Gaps" name="skill_gaps">
<field name="skill_gaps"/>
</page>
<page string="Question Type Weaknesses" name="question_type_weaknesses">
<field name="question_type_weaknesses"/>
</page>
<page string="Topic Weaknesses" name="topic_weaknesses">
<field name="topic_weaknesses"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_gap_profile_list" model="ir.ui.view">
<field name="name">encoach.gap.profile.list</field>
<field name="model">encoach.gap.profile</field>
<field name="arch" type="xml">
<list string="Gap Profiles">
<field name="student_id"/>
<field name="source_type"/>
<field name="entity_id"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_gap_profile_search" model="ir.ui.view">
<field name="name">encoach.gap.profile.search</field>
<field name="model">encoach.gap.profile</field>
<field name="arch" type="xml">
<search string="Search Gap Profiles">
<field name="student_id"/>
<separator/>
<filter string="Placement" name="placement" domain="[('source_type', '=', 'placement')]"/>
<filter string="Exam" name="exam" domain="[('source_type', '=', 'exam')]"/>
<separator/>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
</search>
</field>
</record>
<record id="action_gap_profile" model="ir.actions.act_window">
<field name="name">Gap Analysis</field>
<field name="res_model">encoach.gap.profile</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,15 @@
{
'name': 'EnCoach Entity Onboarding',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Entity student CSV bulk upload, account creation, credential management',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core'],
'data': [
'security/ir.model.access.csv',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,292 @@
import json
import logging
import re
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
class EncoachEntityOnboardController(http.Controller):
# ------------------------------------------------------------------
# POST /api/entity/students/validate-csv
# ------------------------------------------------------------------
@http.route('/api/entity/students/validate-csv', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def validate_csv(self, **kw):
try:
uploaded = request.httprequest.files.get('file')
if not uploaded:
return _error_response('CSV file is required (multipart field "file")', 400)
raw = uploaded.read()
from odoo.addons.encoach_entity_onboard.services.csv_parser import CsvParser
parser = CsvParser()
result = parser.validate(raw)
valid_rows = result['valid_rows']
parse_errors = result['errors']
row_errors = []
for err in parse_errors:
row_errors.append({'row': 0, 'field': '', 'message': err})
User = request.env['res.users'].sudo()
dedup_errors = []
for idx, row in enumerate(valid_rows):
email = row.get('email', '')
if email and not EMAIL_RE.match(email):
row_errors.append({
'row': idx + 2,
'field': 'email',
'message': f"Invalid email format: {email}",
})
continue
if email:
existing = User.search([('login', '=', email)], limit=1)
if existing:
dedup_errors.append({
'row': idx + 2,
'field': 'email',
'message': f"Duplicate email already exists in DB: {email}",
})
all_errors = row_errors + dedup_errors
error_count = len(all_errors)
truly_valid = [
r for i, r in enumerate(valid_rows)
if not any(e['row'] == i + 2 for e in all_errors)
]
preview = truly_valid[:5]
return _json_response({
'valid_count': len(truly_valid),
'error_count': error_count,
'errors': all_errors,
'preview': preview,
})
except Exception as e:
_logger.exception('validate_csv failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/bulk-create
# ------------------------------------------------------------------
@http.route('/api/entity/students/bulk-create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def bulk_create(self, **kw):
try:
body = _get_json_body()
rows = body.get('rows', [])
entity_id = body.get('entity_id')
if not rows:
return _error_response('rows array is required', 400)
if not entity_id:
return _error_response('entity_id is required', 400)
entity_id = int(entity_id)
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
created_users = service.create_accounts(request.env, rows, entity_id)
return _json_response({
'created_count': len(created_users),
'user_ids': created_users.ids,
}, 201)
except Exception as e:
_logger.exception('bulk_create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/send-credentials
# ------------------------------------------------------------------
@http.route('/api/entity/students/send-credentials', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def send_credentials(self, **kw):
try:
body = _get_json_body()
user_ids = body.get('user_ids', [])
if not user_ids:
return _error_response('user_ids array is required', 400)
user_ids = [int(uid) for uid in user_ids]
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
service.send_credentials(request.env, user_ids)
return _json_response({'sent_count': len(user_ids)})
except Exception as e:
_logger.exception('send_credentials failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/<int:student_id>/resend-credentials
# ------------------------------------------------------------------
@http.route('/api/entity/students/<int:student_id>/resend-credentials',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def resend_credentials(self, student_id, **kw):
try:
User = request.env['res.users'].sudo()
user = User.browse(student_id)
if not user.exists():
return _error_response('Student not found', 404)
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
service.resend_credentials(request.env, student_id)
return _json_response({'sent': True})
except Exception as e:
_logger.exception('resend_credentials failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/resend-all-pending
# ------------------------------------------------------------------
@http.route('/api/entity/students/resend-all-pending', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def resend_all_pending(self, **kw):
try:
user = request.env.user.sudo()
domain = [
('first_login', '=', True),
('account_source', '=', 'entity_bulk_upload'),
]
if user.entity_ids:
domain.append(('entity_ids', 'in', user.entity_ids.ids))
User = request.env['res.users'].sudo()
pending_users = User.search(domain)
if not pending_users:
return _json_response({'sent_count': 0})
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
service.send_credentials(request.env, pending_users.ids)
return _json_response({'sent_count': len(pending_users)})
except Exception as e:
_logger.exception('resend_all_pending failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/sis-import
# ------------------------------------------------------------------
@http.route('/api/entity/students/sis-import', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def sis_import(self, **kw):
try:
sis_data = None
uploaded = request.httprequest.files.get('file')
if uploaded:
raw = uploaded.read()
try:
sis_data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return _error_response('Invalid JSON file', 400)
else:
body = _get_json_body()
sis_data = body.get('sis_data', [])
if not sis_data or not isinstance(sis_data, list):
return _error_response('sis_data must be a non-empty JSON array', 400)
user = request.env.user.sudo()
entity_id = None
if user.entity_ids:
entity_id = user.entity_ids[0].id
User = request.env['res.users'].sudo()
imported_count = 0
updated_count = 0
errors = []
for idx, record in enumerate(sis_data):
email = (record.get('email') or '').strip()
name = (record.get('name') or '').strip()
national_id = (record.get('national_id') or '').strip()
phone = (record.get('phone') or '').strip()
if not email or not name:
errors.append({
'index': idx,
'message': 'name and email are required',
})
continue
if not EMAIL_RE.match(email):
errors.append({
'index': idx,
'message': f'Invalid email: {email}',
})
continue
try:
existing = User.search([('login', '=', email)], limit=1)
if existing:
update_vals = {'name': name}
if phone:
update_vals['phone'] = phone
existing.write(update_vals)
if entity_id and hasattr(existing, 'entity_ids'):
if entity_id not in existing.entity_ids.ids:
existing.write({'entity_ids': [(4, entity_id)]})
updated_count += 1
else:
create_vals = {
'name': name,
'login': email,
'email': email,
'phone': phone,
'password': national_id or email.split('@')[0],
'account_source': 'entity_bulk_upload',
'account_status': 'activated',
}
if entity_id:
create_vals['entity_id'] = entity_id
User.create(create_vals)
imported_count += 1
except Exception as rec_err:
errors.append({
'index': idx,
'message': str(rec_err),
})
_logger.warning('SIS import error for row %d: %s', idx, rec_err)
return _json_response({
'imported_count': imported_count,
'updated_count': updated_count,
'errors': errors,
})
except Exception as e:
_logger.exception('sis_import failed')
return _error_response(str(e), 500)

View File

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

View File

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

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