feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

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

View 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,
}

View File

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

View File

@@ -0,0 +1,344 @@
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='public',
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()
role = body.get('role', 'student')
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)
valid_roles = ('academic_student', 'self_learner', 'teacher', 'student')
if role not in valid_roles:
return _error_response(f'role must be one of: {", ".join(valid_roles)}', 400)
# Map frontend role names to user_type values
role_to_user_type = {
'academic_student': 'student',
'self_learner': 'student',
'teacher': 'teacher',
'student': 'student',
}
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)
company = request.env['res.company'].sudo().search([], limit=1)
user = request.env['res.users'].sudo().with_context(
no_reset_password=True,
).create({
'login': email,
'email': email,
'name': name,
'password': password,
'company_id': company.id,
'company_ids': [(4, company.id)],
'user_type': role_to_user_type.get(role, 'student'),
'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.',
'user_id': user.id,
'email': email,
'role': role,
}, 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:
# Dynamic goals from exam templates in DB
Template = request.env['encoach.exam.template'].sudo()
templates = Template.search([('active', '=', True)])
seen = set()
goals = []
icon_map = {
'ielts': 'fa-language',
'general_english': 'fa-comments',
'general english': 'fa-comments',
'academic': 'fa-graduation-cap',
'math': 'fa-calculator',
'mathematics': 'fa-calculator',
'it': 'fa-laptop',
'information technology': 'fa-laptop',
}
for tpl in templates:
subj = tpl.subject_id
if subj and subj.name and subj.name.lower() not in seen:
seen.add(subj.name.lower())
goal_id = subj.name.lower().replace(' ', '_')
goals.append({
'id': goal_id,
'name': subj.name,
'icon': icon_map.get(goal_id, icon_map.get(subj.name.lower(), 'fa-book')),
})
# Fallback if no templates found
if not goals:
goals = [
{'id': 'ielts', 'name': 'IELTS Preparation', 'icon': 'fa-language'},
{'id': 'general_english', 'name': 'General English', 'icon': 'fa-comments'},
{'id': 'math', 'name': 'Mathematics', 'icon': 'fa-calculator'},
{'id': 'it', 'name': 'Information Technology', 'icon': 'fa-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)
skip_placement = body.get('skip_placement', False)
placement_decision = 'skip' if skip_placement else 'take_now'
# learning_style: accept as JSON array ["visual","audio"] or string
learning_style_raw = body.get('learning_style')
if isinstance(learning_style_raw, list):
valid_styles = {'visual', 'audio', 'reading', 'mixed'}
learning_style_raw = [s for s in learning_style_raw if s in valid_styles]
learning_style_json = json.dumps(learning_style_raw) if learning_style_raw else None
elif isinstance(learning_style_raw, str):
learning_style_json = json.dumps([learning_style_raw])
else:
learning_style_json = None
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'),
'placement_decision': placement_decision,
}
if learning_style_json:
profile_vals['learning_style'] = learning_style_json
# Assign B1 default level when placement test is skipped
if skip_placement:
profile_vals['cefr_level'] = 'b1'
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,
'placement_decision': placement_decision,
})
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)

View File

@@ -0,0 +1,2 @@
from . import otp
from . import student_profile

View 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)

View File

@@ -0,0 +1,52 @@
import json
from odoo import models, fields, api
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(help='JSON array of styles: visual, audio, reading, mixed')
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)
placement_decision = fields.Selection([
('take_now', 'Take Now'),
('skip', 'Skip'),
])
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
def get_learning_styles(self):
"""Return learning_style as a Python list."""
self.ensure_one()
if not self.learning_style:
return []
try:
return json.loads(self.learning_style)
except (json.JSONDecodeError, TypeError):
return [self.learning_style] if self.learning_style else []
def set_learning_styles(self, styles):
"""Accept a list of styles and store as JSON."""
self.ensure_one()
if isinstance(styles, list):
self.learning_style = json.dumps(styles)
elif isinstance(styles, str):
self.learning_style = json.dumps([styles])

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_otp_user encoach.otp.user model_encoach_otp base.group_user 1 1 1 1
3 access_encoach_student_profile_user encoach.student.profile.user model_encoach_student_profile base.group_user 1 1 1 1

View File

@@ -0,0 +1,2 @@
from . import captcha_service
from . import otp_service

View File

@@ -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

View File

@@ -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})

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,74 @@
<?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"/>
<field name="cefr_level"/>
<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/>
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
</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>