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