feat: EnCoach V2 — complete OWL refactor with 15 new modules
Full architectural refactor from React to Odoo OWL: - 15 new Odoo modules: signup, placement, exam_template, scoring, course_gen, entity_onboard, ai_course, quality_gate, ielts_validation, pdf_report, verification, math, it, portal, exam_session - 6 modified modules: core (new user fields), exam (template types), adaptive (events/paths/settings), branding (white-label), resources (CEFR/AI fields), taxonomy (Math+IT hierarchies) - ~79 new REST API endpoints across all controller packages - ~50 admin backend views (form/list/kanban/search/menu) - 5 custom OWL components: dashboard, content pool, exam validation, gap analysis, adaptive timeline - POS-inspired full-screen exam session with 9 question renderers - Student portal with 12 website pages (QWeb + OWL) - AI/ML services: IRT 3PL CAT engine, CEFR mapper, quality gates, IELTS validator, SymPy math scorer, speaking evaluator, adaptive engine - Seed data: IELTS/TOEFL/STEP/IC3 templates, 30+ sample questions, English/Math/IT taxonomy trees with 50+ topics - Updated requirements.txt with new dependencies Made-with: Cursor
This commit is contained in:
3
new_project/custom_addons/encoach_signup/__init__.py
Normal file
3
new_project/custom_addons/encoach_signup/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
18
new_project/custom_addons/encoach_signup/__manifest__.py
Normal file
18
new_project/custom_addons/encoach_signup/__manifest__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
'name': 'EnCoach Signup',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'User registration, OTP verification, CAPTCHA, and onboarding wizard',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/otp_views.xml',
|
||||
'views/student_profile_views.xml',
|
||||
'views/signup_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import auth
|
||||
277
new_project/custom_addons/encoach_signup/controllers/auth.py
Normal file
277
new_project/custom_addons/encoach_signup/controllers/auth.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from datetime import timedelta
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
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 EncoachSignupController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/auth/register
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/auth/register', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
def register(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
email = (body.get('email') or '').strip().lower()
|
||||
password = body.get('password')
|
||||
name = body.get('name', '').strip()
|
||||
captcha_token = body.get('captcha_token')
|
||||
|
||||
if not email or not password or not name:
|
||||
return _error_response('email, password, and name are required', 400)
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
secret_key = ICP.get_param('encoach.captcha_secret_key', '')
|
||||
provider = ICP.get_param('encoach.captcha_provider', 'recaptcha')
|
||||
|
||||
if captcha_token and secret_key:
|
||||
verify_urls = {
|
||||
'recaptcha': 'https://www.google.com/recaptcha/api/siteverify',
|
||||
'hcaptcha': 'https://hcaptcha.com/siteverify',
|
||||
'turnstile': 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
}
|
||||
verify_url = verify_urls.get(provider)
|
||||
if verify_url:
|
||||
try:
|
||||
resp = http_requests.post(verify_url, data={
|
||||
'secret': secret_key,
|
||||
'response': captcha_token,
|
||||
}, timeout=10)
|
||||
if not resp.json().get('success'):
|
||||
return _error_response('CAPTCHA verification failed', 400)
|
||||
except Exception:
|
||||
return _error_response('CAPTCHA verification failed', 400)
|
||||
|
||||
existing = request.env['res.users'].sudo().search(
|
||||
[('login', '=', email)], limit=1)
|
||||
if existing:
|
||||
return _error_response('Email already registered', 409)
|
||||
|
||||
user = request.env['res.users'].sudo().create({
|
||||
'login': email,
|
||||
'email': email,
|
||||
'name': name,
|
||||
'password': password,
|
||||
'account_source': 'self_registered',
|
||||
'account_status': 'unactivated',
|
||||
})
|
||||
|
||||
otp_code = ''.join(random.choices(string.digits, k=6))
|
||||
otp_hash = hashlib.sha256(otp_code.encode()).hexdigest()
|
||||
expires_at = fields.Datetime.now() + timedelta(minutes=15)
|
||||
|
||||
request.env['encoach.otp'].sudo().create({
|
||||
'email': email,
|
||||
'otp_hash': otp_hash,
|
||||
'expires_at': expires_at,
|
||||
})
|
||||
|
||||
_logger.info('Registration OTP for %s: %s', email, otp_code)
|
||||
|
||||
return _json_response({
|
||||
'message': 'Registration successful. Please verify your email.',
|
||||
'email': email,
|
||||
}, 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('register failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/auth/check-email
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/auth/check-email', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
def check_email(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
email = (body.get('email') or '').strip().lower()
|
||||
if not email:
|
||||
return _error_response('email is required', 400)
|
||||
|
||||
exists = bool(request.env['res.users'].sudo().search(
|
||||
[('login', '=', email)], limit=1))
|
||||
return _json_response({'exists': exists})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('check_email failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/auth/verify-email
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/auth/verify-email', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
def verify_email(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
email = (body.get('email') or '').strip().lower()
|
||||
otp = body.get('otp', '').strip()
|
||||
|
||||
if not email or not otp:
|
||||
return _error_response('email and otp are required', 400)
|
||||
|
||||
otp_hash = hashlib.sha256(otp.encode()).hexdigest()
|
||||
now = fields.Datetime.now()
|
||||
|
||||
record = request.env['encoach.otp'].sudo().search([
|
||||
('email', '=', email),
|
||||
('otp_hash', '=', otp_hash),
|
||||
('used', '=', False),
|
||||
('expires_at', '>=', now),
|
||||
], limit=1, order='created_at desc')
|
||||
|
||||
if not record:
|
||||
return _error_response('Invalid or expired OTP', 400)
|
||||
|
||||
record.write({'used': True})
|
||||
|
||||
user = request.env['res.users'].sudo().search(
|
||||
[('login', '=', email)], limit=1)
|
||||
if user:
|
||||
user.write({'is_verified': True})
|
||||
|
||||
return _json_response({'verified': True})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('verify_email failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/auth/resend-otp
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/auth/resend-otp', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
def resend_otp(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
email = (body.get('email') or '').strip().lower()
|
||||
if not email:
|
||||
return _error_response('email is required', 400)
|
||||
|
||||
latest = request.env['encoach.otp'].sudo().search([
|
||||
('email', '=', email),
|
||||
], limit=1, order='created_at desc')
|
||||
|
||||
if latest and latest.resend_count >= 3:
|
||||
return _error_response('Maximum resend attempts reached', 429)
|
||||
|
||||
otp_code = ''.join(random.choices(string.digits, k=6))
|
||||
otp_hash = hashlib.sha256(otp_code.encode()).hexdigest()
|
||||
expires_at = fields.Datetime.now() + timedelta(minutes=15)
|
||||
new_count = (latest.resend_count + 1) if latest else 1
|
||||
|
||||
request.env['encoach.otp'].sudo().create({
|
||||
'email': email,
|
||||
'otp_hash': otp_hash,
|
||||
'expires_at': expires_at,
|
||||
'resend_count': new_count,
|
||||
})
|
||||
|
||||
_logger.info('Resend OTP for %s: %s', email, otp_code)
|
||||
|
||||
return _json_response({'message': 'OTP resent'})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('resend_otp failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/onboarding/goals
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/onboarding/goals', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def onboarding_goals(self, **kw):
|
||||
try:
|
||||
goals = [
|
||||
{'id': 'ielts', 'name': 'IELTS Preparation',
|
||||
'icon': 'mdi-book-education'},
|
||||
{'id': 'general_english', 'name': 'General English',
|
||||
'icon': 'mdi-translate'},
|
||||
{'id': 'math', 'name': 'Mathematics',
|
||||
'icon': 'mdi-calculator-variant'},
|
||||
{'id': 'it', 'name': 'Information Technology',
|
||||
'icon': 'mdi-laptop'},
|
||||
]
|
||||
return _json_response({'goals': goals})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('onboarding_goals failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/onboarding/complete
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/onboarding/complete', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def onboarding_complete(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
user = request.env.user
|
||||
|
||||
learning_goal = body.get('learning_goal')
|
||||
if not learning_goal:
|
||||
return _error_response('learning_goal is required', 400)
|
||||
|
||||
profile_vals = {
|
||||
'user_id': user.id,
|
||||
'learning_goal': learning_goal,
|
||||
'target_band': body.get('target_band', 0.0),
|
||||
'hours_per_week': body.get('hours_per_week', 0),
|
||||
'study_mode': body.get('study_mode'),
|
||||
'exam_date': body.get('exam_date'),
|
||||
}
|
||||
|
||||
Profile = request.env['encoach.student.profile'].sudo()
|
||||
profile = Profile.search([('user_id', '=', user.id)], limit=1)
|
||||
if profile:
|
||||
profile.write(profile_vals)
|
||||
else:
|
||||
profile = Profile.create(profile_vals)
|
||||
|
||||
user.sudo().write({
|
||||
'account_status': 'activated',
|
||||
'first_login': False,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'message': 'Onboarding complete',
|
||||
'profile_id': profile.id,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('onboarding_complete failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/config/captcha
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/config/captcha', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
def captcha_config(self, **kw):
|
||||
try:
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
return _json_response({
|
||||
'provider': ICP.get_param('encoach.captcha_provider', 'recaptcha'),
|
||||
'site_key': ICP.get_param('encoach.captcha_site_key', ''),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('captcha_config failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import otp
|
||||
from . import student_profile
|
||||
13
new_project/custom_addons/encoach_signup/models/otp.py
Normal file
13
new_project/custom_addons/encoach_signup/models/otp.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachOtp(models.Model):
|
||||
_name = 'encoach.otp'
|
||||
_description = 'OTP Verification Record'
|
||||
|
||||
email = fields.Char(required=True, index=True)
|
||||
otp_hash = fields.Char(required=True)
|
||||
expires_at = fields.Datetime(required=True)
|
||||
used = fields.Boolean(default=False)
|
||||
resend_count = fields.Integer(default=0)
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
@@ -0,0 +1,28 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachStudentProfile(models.Model):
|
||||
_name = 'encoach.student.profile'
|
||||
_description = 'Student Onboarding Profile'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
('a1', 'A1'),
|
||||
('a2', 'A2'),
|
||||
('b1', 'B1'),
|
||||
('b2', 'B2'),
|
||||
('c1', 'C1'),
|
||||
('c2', 'C2'),
|
||||
])
|
||||
target_band = fields.Float()
|
||||
learning_style = fields.Text()
|
||||
learning_goal = fields.Char(size=200)
|
||||
hours_per_week = fields.Integer()
|
||||
study_mode = fields.Selection([
|
||||
('self_study', 'Self Study'),
|
||||
('with_teacher', 'With Teacher'),
|
||||
])
|
||||
exam_date = fields.Date()
|
||||
placement_completed = fields.Boolean(default=False)
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_otp_user,encoach.otp.user,model_encoach_otp,base.group_user,1,1,1,1
|
||||
access_encoach_student_profile_user,encoach.student.profile.user,model_encoach_student_profile,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,2 @@
|
||||
from . import captcha_service
|
||||
from . import otp_service
|
||||
@@ -0,0 +1,33 @@
|
||||
import requests
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
|
||||
class CaptchaService:
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def verify(self, token):
|
||||
"""Verify CAPTCHA token against configured provider."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
secret_key = ICP.get_param('encoach.captcha_secret_key', '')
|
||||
provider = ICP.get_param('encoach.captcha_provider', 'recaptcha')
|
||||
|
||||
if provider == 'recaptcha':
|
||||
url = 'https://www.google.com/recaptcha/api/siteverify'
|
||||
elif provider == 'hcaptcha':
|
||||
url = 'https://hcaptcha.com/siteverify'
|
||||
elif provider == 'turnstile':
|
||||
url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
else:
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = requests.post(url, data={
|
||||
'secret': secret_key,
|
||||
'response': token,
|
||||
}, timeout=10)
|
||||
result = resp.json()
|
||||
return result.get('success', False)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,59 @@
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import fields
|
||||
|
||||
|
||||
class OtpService:
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def _hash_otp(self, otp_code):
|
||||
return hashlib.sha256(otp_code.encode()).hexdigest()
|
||||
|
||||
def generate(self, email):
|
||||
"""Create a 6-digit OTP, store its SHA-256 hash, return plaintext."""
|
||||
otp_code = ''.join(random.choices(string.digits, k=6))
|
||||
otp_hash = self._hash_otp(otp_code)
|
||||
expires_at = fields.Datetime.now() + timedelta(minutes=15)
|
||||
|
||||
self.env['encoach.otp'].sudo().create({
|
||||
'email': email,
|
||||
'otp_hash': otp_hash,
|
||||
'expires_at': expires_at,
|
||||
})
|
||||
return otp_code
|
||||
|
||||
def verify(self, email, otp_code):
|
||||
"""Find unexpired, unused record and check SHA-256 hash match."""
|
||||
otp_hash = self._hash_otp(otp_code)
|
||||
now = fields.Datetime.now()
|
||||
record = self.env['encoach.otp'].sudo().search([
|
||||
('email', '=', email),
|
||||
('otp_hash', '=', otp_hash),
|
||||
('used', '=', False),
|
||||
('expires_at', '>=', now),
|
||||
], limit=1, order='created_at desc')
|
||||
|
||||
if record:
|
||||
record.write({'used': True})
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_resend(self, email):
|
||||
"""Check if resend_count < 3 for the latest OTP."""
|
||||
record = self.env['encoach.otp'].sudo().search([
|
||||
('email', '=', email),
|
||||
], limit=1, order='created_at desc')
|
||||
return record and record.resend_count < 3
|
||||
|
||||
def mark_resend(self, email):
|
||||
"""Increment resend_count on the latest OTP for this email."""
|
||||
record = self.env['encoach.otp'].sudo().search([
|
||||
('email', '=', email),
|
||||
], limit=1, order='created_at desc')
|
||||
if record:
|
||||
record.write({'resend_count': record.resend_count + 1})
|
||||
61
new_project/custom_addons/encoach_signup/views/otp_views.xml
Normal file
61
new_project/custom_addons/encoach_signup/views/otp_views.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_otp_form" model="ir.ui.view">
|
||||
<field name="name">encoach.otp.form</field>
|
||||
<field name="model">encoach.otp</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="OTP Record">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="email"/>
|
||||
<field name="otp_hash"/>
|
||||
<field name="expires_at"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="used"/>
|
||||
<field name="resend_count"/>
|
||||
<field name="created_at"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_otp_list" model="ir.ui.view">
|
||||
<field name="name">encoach.otp.list</field>
|
||||
<field name="model">encoach.otp</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="OTP Records" create="false" edit="false">
|
||||
<field name="email"/>
|
||||
<field name="otp_hash"/>
|
||||
<field name="expires_at"/>
|
||||
<field name="used" widget="badge" decoration-success="used" decoration-muted="not used"/>
|
||||
<field name="resend_count"/>
|
||||
<field name="created_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_otp_search" model="ir.ui.view">
|
||||
<field name="name">encoach.otp.search</field>
|
||||
<field name="model">encoach.otp</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search OTP Records">
|
||||
<field name="email"/>
|
||||
<separator/>
|
||||
<filter string="Used" name="used" domain="[('used', '=', True)]"/>
|
||||
<filter string="Unused" name="unused" domain="[('used', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_otp" model="ir.actions.act_window">
|
||||
<field name="name">OTP Records</field>
|
||||
<field name="res_model">encoach.otp</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_student_profiles"
|
||||
name="Student Profiles"
|
||||
parent="encoach_core.menu_encoach_students"
|
||||
action="encoach_signup.action_student_profile"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_otp_records"
|
||||
name="OTP Records"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="encoach_signup.action_otp"
|
||||
sequence="50"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_student_profile_form" model="ir.ui.view">
|
||||
<field name="name">encoach.student.profile.form</field>
|
||||
<field name="model">encoach.student.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Student Profile">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="user_id"/>
|
||||
<field name="cefr_level"/>
|
||||
<field name="target_band"/>
|
||||
<field name="learning_goal"/>
|
||||
<field name="hours_per_week"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="study_mode"/>
|
||||
<field name="exam_date"/>
|
||||
<field name="placement_completed"/>
|
||||
<field name="entity_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="learning_style"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_student_profile_list" model="ir.ui.view">
|
||||
<field name="name">encoach.student.profile.list</field>
|
||||
<field name="model">encoach.student.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Student Profiles">
|
||||
<field name="user_id"/>
|
||||
<field name="cefr_level"/>
|
||||
<field name="target_band"/>
|
||||
<field name="study_mode"/>
|
||||
<field name="placement_completed"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_student_profile_search" model="ir.ui.view">
|
||||
<field name="name">encoach.student.profile.search</field>
|
||||
<field name="model">encoach.student.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Student Profiles">
|
||||
<field name="user_id"/>
|
||||
<separator/>
|
||||
<filter string="Pre-A1" name="pre_a1" domain="[('cefr_level', '=', 'pre_a1')]"/>
|
||||
<filter string="A1" name="a1" domain="[('cefr_level', '=', 'a1')]"/>
|
||||
<filter string="A2" name="a2" domain="[('cefr_level', '=', 'a2')]"/>
|
||||
<filter string="B1" name="b1" domain="[('cefr_level', '=', 'b1')]"/>
|
||||
<filter string="B2" name="b2" domain="[('cefr_level', '=', 'b2')]"/>
|
||||
<filter string="C1" name="c1" domain="[('cefr_level', '=', 'c1')]"/>
|
||||
<filter string="C2" name="c2" domain="[('cefr_level', '=', 'c2')]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_student_profile" model="ir.actions.act_window">
|
||||
<field name="name">Student Profiles</field>
|
||||
<field name="res_model">encoach.student.profile</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user