feat(v3): restructure project + add complete frontend

- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 907a5c0e92
331 changed files with 23511 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,21 @@
{
'name': 'EnCoach Placement',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_exam_template'],
'data': [
'security/ir.model.access.csv',
'views/cat_session_views.xml',
'views/student_ability_views.xml',
'views/placement_menus.xml',
],
'external_dependencies': {
'python': ['numpy'],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,403 @@
import json
import logging
import math
import base64
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__)
LEARNING_RATE = 0.3
SEM_THRESHOLD = 0.3
MAX_QUESTIONS = 40
THETA_CEFR = [
(-3.0, 'pre_a1'), (-2.0, 'a1'), (-1.0, 'a2'),
(0.0, 'b1'), (1.0, 'b2'), (2.0, 'c1'), (3.0, 'c2'),
]
def _theta_to_cefr(theta):
for boundary, level in THETA_CEFR:
if theta <= boundary:
return level
return 'c2'
def _irt_probability(theta, a, b, c):
"""3PL IRT probability."""
exp_val = -a * (theta - b)
exp_val = max(min(exp_val, 500), -500)
return c + (1.0 - c) / (1.0 + math.exp(exp_val))
def _fisher_info(theta, a, b, c):
"""Fisher information for a 3PL item."""
p = _irt_probability(theta, a, b, c)
q = 1.0 - p
if (1.0 - c) == 0 or p == 0:
return 0.0
numerator = (a ** 2) * ((p - c) ** 2) * q
denominator = ((1.0 - c) ** 2) * p
return numerator / denominator if denominator else 0.0
def _select_next(theta, available_qs, answered_ids):
"""Pick the item with max Fisher information from unanswered pool."""
best, best_info = None, -1.0
for q in available_qs:
if q['id'] in answered_ids:
continue
info = _fisher_info(theta, q['irt_a'], q['irt_b'], q['irt_c'])
if info > best_info:
best_info = info
best = q
return best
def _question_to_dict(rec):
options = None
if rec.options:
try:
options = json.loads(rec.options)
except (json.JSONDecodeError, TypeError):
options = rec.options
return {
'id': rec.id,
'stem': rec.stem,
'options': options,
'question_type': rec.question_type,
'skill': rec.skill,
}
class EncoachPlacementController(http.Controller):
# ------------------------------------------------------------------
# POST /api/placement/start
# ------------------------------------------------------------------
@http.route('/api/placement/start', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def start(self, **kw):
try:
user = request.env.user
active_session = request.env['encoach.cat.session'].sudo().search([
('student_id', '=', user.id),
('status', '=', 'active'),
], limit=1)
if active_session:
active_session.write({'status': 'abandoned'})
session = request.env['encoach.cat.session'].sudo().create({
'student_id': user.id,
'status': 'active',
'current_theta': 0.0,
'current_sem': 1.0,
'questions_answered': 0,
})
questions = request.env['encoach.question'].sudo().search(
[('status', '=', 'active')])
q_dicts = [{
'id': q.id, 'irt_a': q.irt_a,
'irt_b': q.irt_b, 'irt_c': q.irt_c,
} for q in questions]
first = _select_next(0.0, q_dicts, set())
first_question = None
if first:
rec = request.env['encoach.question'].sudo().browse(first['id'])
first_question = _question_to_dict(rec)
return _json_response({
'session_id': session.id,
'first_question': first_question,
})
except Exception as e:
_logger.exception('placement start failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/placement/answer
# ------------------------------------------------------------------
@http.route('/api/placement/answer', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def answer(self, **kw):
try:
body = _get_json_body()
session_id = body.get('session_id')
question_id = body.get('question_id')
answer = body.get('answer')
if not session_id or not question_id:
return _error_response('session_id and question_id are required', 400)
session = request.env['encoach.cat.session'].sudo().browse(int(session_id))
if not session.exists() or session.status != 'active':
return _error_response('Invalid or inactive session', 400)
question = request.env['encoach.question'].sudo().browse(int(question_id))
if not question.exists():
return _error_response('Question not found', 404)
correct_answer = (question.correct_answer or '').strip()
given_answer = str(answer or '').strip()
correct = given_answer.lower() == correct_answer.lower()
theta = session.current_theta
a, b, c = question.irt_a, question.irt_b, question.irt_c
p = _irt_probability(theta, a, b, c)
theta += LEARNING_RATE * ((1.0 if correct else 0.0) - p)
theta = max(-4.0, min(4.0, theta))
answered_ids = set()
autosave = session.autosave_data
if autosave:
try:
saved = json.loads(autosave)
answered_ids = set(saved.get('answered_ids', []))
except (json.JSONDecodeError, TypeError):
pass
answered_ids.add(question_id)
all_qs = request.env['encoach.question'].sudo().search(
[('status', '=', 'active')])
total_info = 0.0
for q in all_qs:
if q.id in answered_ids:
total_info += _fisher_info(theta, q.irt_a, q.irt_b, q.irt_c)
sem = 1.0 / math.sqrt(total_info) if total_info > 0 else 1.0
questions_answered = session.questions_answered + 1
completed = sem < SEM_THRESHOLD or questions_answered >= MAX_QUESTIONS
write_vals = {
'current_theta': theta,
'current_sem': sem,
'questions_answered': questions_answered,
'autosave_data': json.dumps({
'answered_ids': list(answered_ids),
}),
}
result = {
'correct': correct,
'new_theta': round(theta, 4),
'new_sem': round(sem, 4),
'completed': completed,
}
if completed:
cefr = _theta_to_cefr(theta)
write_vals['status'] = 'completed'
write_vals['completed_at'] = fields.Datetime.now()
result['cefr_level'] = cefr
result['next_question'] = None
Ability = request.env['encoach.student.ability.model'].sudo()
ability = Ability.search([
('student_id', '=', session.student_id.id),
('skill', '=', question.skill),
], limit=1)
if ability:
ability.write({'theta': theta, 'sem': sem,
'last_updated': fields.Datetime.now()})
else:
Ability.create({
'student_id': session.student_id.id,
'skill': question.skill,
'theta': theta,
'sem': sem,
})
else:
q_dicts = [{
'id': q.id, 'irt_a': q.irt_a,
'irt_b': q.irt_b, 'irt_c': q.irt_c,
} for q in all_qs]
nxt = _select_next(theta, q_dicts, answered_ids)
if nxt:
rec = request.env['encoach.question'].sudo().browse(nxt['id'])
result['next_question'] = _question_to_dict(rec)
else:
result['next_question'] = None
session.write(write_vals)
return _json_response(result)
except Exception as e:
_logger.exception('placement answer failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/placement/autosave
# ------------------------------------------------------------------
@http.route('/api/placement/autosave', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def autosave(self, **kw):
try:
body = _get_json_body()
session_id = body.get('session_id')
autosave_data = body.get('autosave_data')
if not session_id:
return _error_response('session_id is required', 400)
session = request.env['encoach.cat.session'].sudo().browse(int(session_id))
if not session.exists():
return _error_response('Session not found', 404)
session.write({
'autosave_data': json.dumps(autosave_data) if not isinstance(
autosave_data, str) else autosave_data,
})
return _json_response({'saved': True})
except Exception as e:
_logger.exception('placement autosave failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/placement/speaking-upload
# ------------------------------------------------------------------
@http.route('/api/placement/speaking-upload', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def speaking_upload(self, **kw):
try:
audio_file = request.httprequest.files.get('audio')
if not audio_file:
return _error_response('No audio file provided', 400)
file_data = audio_file.read()
attachment = request.env['ir.attachment'].sudo().create({
'name': audio_file.filename or 'speaking_upload.webm',
'type': 'binary',
'datas': base64.b64encode(file_data),
'res_model': 'encoach.cat.session',
'mimetype': audio_file.content_type or 'audio/webm',
})
return _json_response({
'upload_id': attachment.id,
'status': 'processing',
})
except Exception as e:
_logger.exception('speaking upload failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/placement/speaking-status
# ------------------------------------------------------------------
@http.route('/api/placement/speaking-status', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def speaking_status(self, **kw):
try:
upload_id = kw.get('upload_id')
if not upload_id:
return _error_response('upload_id is required', 400)
attachment = request.env['ir.attachment'].sudo().browse(int(upload_id))
if not attachment.exists():
return _error_response('Upload not found', 404)
return _json_response({
'status': 'completed',
'transcription': None,
})
except Exception as e:
_logger.exception('speaking status failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/placement/results
# ------------------------------------------------------------------
@http.route('/api/placement/results', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def results(self, **kw):
try:
user = request.env.user
session = request.env['encoach.cat.session'].sudo().search([
('student_id', '=', user.id),
('status', '=', 'completed'),
], limit=1, order='completed_at desc')
if not session:
return _error_response('No completed placement found', 404)
abilities = request.env['encoach.student.ability.model'].sudo().search([
('student_id', '=', user.id),
])
skills = [{
'skill': ab.skill,
'theta': round(ab.theta, 4),
'cefr_level': _theta_to_cefr(ab.theta),
} for ab in abilities]
return _json_response({
'cefr_level': _theta_to_cefr(session.current_theta),
'skills': skills,
'placement_date': session.completed_at,
})
except Exception as e:
_logger.exception('placement results failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/placement/learning-path
# ------------------------------------------------------------------
@http.route('/api/placement/learning-path', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def learning_path(self, **kw):
try:
user = request.env.user
abilities = request.env['encoach.student.ability.model'].sudo().search([
('student_id', '=', user.id),
])
gap_areas = []
for ab in abilities:
if ab.theta < 0.0:
gap_areas.append({
'skill': ab.skill,
'current_level': _theta_to_cefr(ab.theta),
'theta': round(ab.theta, 4),
})
recommended = []
cefr_map = {}
for ab in abilities:
cefr_map[ab.skill] = _theta_to_cefr(ab.theta)
weakest_skills = sorted(abilities, key=lambda a: a.theta)
for ab in weakest_skills[:3]:
recommended.append({
'skill': ab.skill,
'course_type': 'remedial',
'suggested_level': _theta_to_cefr(ab.theta),
'priority': 'high' if ab.theta < -1.0 else 'medium',
})
return _json_response({
'recommended_courses': recommended,
'gap_areas': gap_areas,
})
except Exception as e:
_logger.exception('learning path failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,2 @@
from . import cat_session
from . import student_ability

View File

@@ -0,0 +1,28 @@
from odoo import models, fields
class EncoachCatSession(models.Model):
_name = 'encoach.cat.session'
_description = 'CAT Placement Session'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
started_at = fields.Datetime(default=fields.Datetime.now)
completed_at = fields.Datetime()
status = fields.Selection([
('active', 'Active'),
('completed', 'Completed'),
('abandoned', 'Abandoned'),
], default='active', required=True)
current_section = fields.Selection([
('listening', 'Listening'),
('reading', 'Reading'),
('writing', 'Writing'),
('speaking', 'Speaking'),
('grammar', 'Grammar'),
('vocabulary', 'Vocabulary'),
])
current_theta = fields.Float(default=0.0)
current_sem = fields.Float(default=1.0)
questions_answered = fields.Integer(default=0)
autosave_data = fields.Text()

View File

@@ -0,0 +1,26 @@
from odoo import models, fields
class EncoachStudentAbilityModel(models.Model):
_name = 'encoach.student.ability.model'
_description = 'Student Ability Estimate per Skill'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
skill = fields.Selection([
('listening', 'Listening'),
('reading', 'Reading'),
('writing', 'Writing'),
('speaking', 'Speaking'),
('grammar', 'Grammar'),
('vocabulary', 'Vocabulary'),
])
theta = fields.Float(default=0.0)
sem = fields.Float(default=1.0)
last_updated = fields.Datetime(default=fields.Datetime.now)
_sql_constraints = [
('unique_student_subject_skill',
'unique(student_id, subject_id, skill)',
'Duplicate ability record'),
]

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_cat_session_user,encoach.cat.session.user,model_encoach_cat_session,base.group_user,1,1,1,1
access_encoach_student_ability_model_user,encoach.student.ability.model.user,model_encoach_student_ability_model,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_cat_session_user encoach.cat.session.user model_encoach_cat_session base.group_user 1 1 1 1
3 access_encoach_student_ability_model_user encoach.student.ability.model.user model_encoach_student_ability_model base.group_user 1 1 1 1

View File

@@ -0,0 +1,2 @@
from .cat_engine import CatEngine
from .cefr_mapper import CefrMapper

View File

@@ -0,0 +1,159 @@
import math
import logging
_logger = logging.getLogger(__name__)
class CatEngine:
"""Computerized Adaptive Testing engine using IRT 3PL model."""
SEM_THRESHOLD = 0.3
MAX_QUESTIONS = 40
MIN_QUESTIONS = 10
THETA_MIN = -4.0
THETA_MAX = 4.0
@staticmethod
def irt_probability(theta, a, b, c):
"""3PL IRT probability of correct response."""
exponent = -a * (theta - b)
exponent = max(min(exponent, 20), -20)
return c + (1 - c) / (1 + math.exp(exponent))
@staticmethod
def fisher_information(theta, a, b, c):
"""Fisher information for a single item at given theta."""
p = CatEngine.irt_probability(theta, a, b, c)
q = 1 - p
if p <= c or q <= 0:
return 0.0
numerator = a**2 * (p - c)**2 * q
denominator = (1 - c)**2 * p
if denominator == 0:
return 0.0
return numerator / denominator
@staticmethod
def select_next_question(theta, available_questions):
"""Select the question with maximum Fisher information at current theta.
available_questions: list of dicts with keys: id, irt_a, irt_b, irt_c
Returns: the selected question dict, or None if empty.
"""
if not available_questions:
return None
best_question = None
best_info = -1.0
for q in available_questions:
a = q.get('irt_a', 1.0)
b = q.get('irt_b', 0.0)
c = q.get('irt_c', 0.25)
info = CatEngine.fisher_information(theta, a, b, c)
if info > best_info:
best_info = info
best_question = q
return best_question
@staticmethod
def update_theta(theta, responses, questions):
"""Update theta using Newton-Raphson MLE.
responses: list of 0/1 (incorrect/correct)
questions: list of dicts with irt_a, irt_b, irt_c
Returns: updated theta, standard error of measurement
"""
if not responses:
return theta, 1.0
current_theta = theta
for _iteration in range(30):
numerator = 0.0
denominator = 0.0
for resp, q in zip(responses, questions):
a = q.get('irt_a', 1.0)
b = q.get('irt_b', 0.0)
c = q.get('irt_c', 0.25)
p = CatEngine.irt_probability(current_theta, a, b, c)
q_val = 1 - p
if p <= c:
continue
w = a * (p - c) / ((1 - c) * p)
numerator += w * (resp - p)
denominator += w**2 * p * q_val
if abs(denominator) < 1e-10:
break
delta = numerator / denominator
current_theta += delta
current_theta = max(CatEngine.THETA_MIN, min(CatEngine.THETA_MAX, current_theta))
if abs(delta) < 0.001:
break
total_info = 0.0
for q in questions:
a = q.get('irt_a', 1.0)
b = q.get('irt_b', 0.0)
c = q.get('irt_c', 0.25)
total_info += CatEngine.fisher_information(current_theta, a, b, c)
sem = 1.0 / math.sqrt(total_info) if total_info > 0 else 1.0
return current_theta, sem
@staticmethod
def check_stopping(sem, questions_answered):
"""Check if CAT should stop.
Returns: (should_stop, reason)
"""
if questions_answered >= CatEngine.MAX_QUESTIONS:
return True, 'max_questions_reached'
if sem < CatEngine.SEM_THRESHOLD and questions_answered >= CatEngine.MIN_QUESTIONS:
return True, 'sem_converged'
return False, None
@staticmethod
def estimate_ability_eap(responses, questions, prior_mean=0.0, prior_sd=1.0, num_points=61):
"""Expected A Posteriori (EAP) ability estimation.
More robust than MLE for short tests.
"""
theta_range = [
CatEngine.THETA_MIN + i * (CatEngine.THETA_MAX - CatEngine.THETA_MIN) / (num_points - 1)
for i in range(num_points)
]
log_posteriors = []
for theta in theta_range:
log_likelihood = 0.0
for resp, q in zip(responses, questions):
a = q.get('irt_a', 1.0)
b = q.get('irt_b', 0.0)
c = q.get('irt_c', 0.25)
p = CatEngine.irt_probability(theta, a, b, c)
p = max(min(p, 0.9999), 0.0001)
if resp == 1:
log_likelihood += math.log(p)
else:
log_likelihood += math.log(1 - p)
log_prior = -0.5 * ((theta - prior_mean) / prior_sd) ** 2
log_posteriors.append(log_likelihood + log_prior)
max_log = max(log_posteriors)
posteriors = [math.exp(lp - max_log) for lp in log_posteriors]
total = sum(posteriors)
posteriors = [p / total for p in posteriors]
eap = sum(t * p for t, p in zip(theta_range, posteriors))
eap_var = sum(p * (t - eap) ** 2 for t, p in zip(theta_range, posteriors))
sem = math.sqrt(eap_var)
return eap, sem

View File

@@ -0,0 +1,83 @@
class CefrMapper:
"""Maps IRT theta values to CEFR levels and IELTS band scores."""
THETA_TO_CEFR = [
(-4.0, -2.5, 'pre_a1'),
(-2.5, -1.5, 'a1'),
(-1.5, -0.5, 'a2'),
(-0.5, 0.5, 'b1'),
(0.5, 1.5, 'b2'),
(1.5, 2.5, 'c1'),
(2.5, 4.0, 'c2'),
]
CEFR_TO_BAND = {
'pre_a1': 2.0,
'a1': 3.0,
'a2': 4.0,
'b1': 5.0,
'b2': 6.5,
'c1': 7.5,
'c2': 9.0,
}
CEFR_LABELS = {
'pre_a1': 'Pre-A1 (Beginner)',
'a1': 'A1 (Elementary)',
'a2': 'A2 (Pre-Intermediate)',
'b1': 'B1 (Intermediate)',
'b2': 'B2 (Upper-Intermediate)',
'c1': 'C1 (Advanced)',
'c2': 'C2 (Proficient)',
}
@staticmethod
def theta_to_cefr(theta):
for low, high, level in CefrMapper.THETA_TO_CEFR:
if low <= theta < high:
return level
return 'c2' if theta >= 2.5 else 'pre_a1'
@staticmethod
def theta_to_band(theta):
cefr = CefrMapper.theta_to_cefr(theta)
base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0)
for low, high, level in CefrMapper.THETA_TO_CEFR:
if level == cefr:
range_width = high - low
if range_width > 0:
position = (theta - low) / range_width
else:
position = 0.5
cefr_list = list(CefrMapper.CEFR_TO_BAND.keys())
idx = cefr_list.index(cefr)
next_band = CefrMapper.CEFR_TO_BAND.get(
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
)
band = base_band + position * (next_band - base_band)
return round(band * 2) / 2
return base_band
@staticmethod
def band_to_cefr(band):
if band < 2.5:
return 'pre_a1'
if band < 3.5:
return 'a1'
if band < 4.5:
return 'a2'
if band < 5.5:
return 'b1'
if band < 7.0:
return 'b2'
if band < 8.0:
return 'c1'
return 'c2'
@staticmethod
def get_cefr_label(cefr_code):
return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code)

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_cat_session_form" model="ir.ui.view">
<field name="name">encoach.cat.session.form</field>
<field name="model">encoach.cat.session</field>
<field name="arch" type="xml">
<form string="CAT Session">
<header>
<field name="status" widget="statusbar" statusbar_visible="active,completed"/>
</header>
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="subject_id"/>
<field name="current_section"/>
</group>
<group>
<field name="started_at"/>
<field name="completed_at"/>
<field name="current_theta"/>
<field name="current_sem"/>
<field name="questions_answered"/>
</group>
</group>
<notebook>
<page string="Autosave Data" name="autosave">
<field name="autosave_data"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_cat_session_list" model="ir.ui.view">
<field name="name">encoach.cat.session.list</field>
<field name="model">encoach.cat.session</field>
<field name="arch" type="xml">
<list string="CAT Sessions">
<field name="student_id"/>
<field name="status" widget="badge" decoration-info="status == 'active'" decoration-success="status == 'completed'" decoration-danger="status == 'abandoned'"/>
<field name="current_theta"/>
<field name="current_sem"/>
<field name="questions_answered"/>
<field name="started_at"/>
</list>
</field>
</record>
<record id="view_cat_session_search" model="ir.ui.view">
<field name="name">encoach.cat.session.search</field>
<field name="model">encoach.cat.session</field>
<field name="arch" type="xml">
<search string="Search CAT Sessions">
<field name="student_id"/>
<separator/>
<filter string="Active" name="active" domain="[('status', '=', 'active')]"/>
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
<filter string="Abandoned" name="abandoned" domain="[('status', '=', 'abandoned')]"/>
<separator/>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
</search>
</field>
</record>
<record id="action_cat_session" model="ir.actions.act_window">
<field name="name">Placements</field>
<field name="res_model">encoach.cat.session</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_placements"
name="Placements"
parent="encoach_core.menu_encoach_students"
action="encoach_placement.action_cat_session"
sequence="20"/>
<menuitem id="menu_student_abilities"
name="Student Abilities"
parent="encoach_core.menu_encoach_students"
action="encoach_placement.action_student_ability"
sequence="30"/>
</odoo>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_student_ability_form" model="ir.ui.view">
<field name="name">encoach.student.ability.model.form</field>
<field name="model">encoach.student.ability.model</field>
<field name="arch" type="xml">
<form string="Student Ability">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="subject_id"/>
<field name="skill"/>
</group>
<group>
<field name="theta"/>
<field name="sem"/>
<field name="last_updated"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_student_ability_list" model="ir.ui.view">
<field name="name">encoach.student.ability.model.list</field>
<field name="model">encoach.student.ability.model</field>
<field name="arch" type="xml">
<list string="Student Abilities">
<field name="student_id"/>
<field name="skill"/>
<field name="theta"/>
<field name="sem"/>
<field name="last_updated"/>
</list>
</field>
</record>
<record id="view_student_ability_search" model="ir.ui.view">
<field name="name">encoach.student.ability.model.search</field>
<field name="model">encoach.student.ability.model</field>
<field name="arch" type="xml">
<search string="Search Student Abilities">
<field name="student_id"/>
<separator/>
<filter string="Listening" name="listening" domain="[('skill', '=', 'listening')]"/>
<filter string="Reading" name="reading" domain="[('skill', '=', 'reading')]"/>
<filter string="Writing" name="writing" domain="[('skill', '=', 'writing')]"/>
<filter string="Speaking" name="speaking" domain="[('skill', '=', 'speaking')]"/>
<filter string="Grammar" name="grammar" domain="[('skill', '=', 'grammar')]"/>
<filter string="Vocabulary" name="vocabulary" domain="[('skill', '=', 'vocabulary')]"/>
<separator/>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Subject" name="group_subject" context="{'group_by': 'subject_id'}"/>
</search>
</field>
</record>
<record id="action_student_ability" model="ir.actions.act_window">
<field name="name">Student Abilities</field>
<field name="res_model">encoach.student.ability.model</field>
<field name="view_mode">list,form</field>
</record>
</odoo>