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