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:
2
custom_addons/encoach_entity_onboard/__init__.py
Normal file
2
custom_addons/encoach_entity_onboard/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import services
|
||||
from . import controllers
|
||||
15
custom_addons/encoach_entity_onboard/__manifest__.py
Normal file
15
custom_addons/encoach_entity_onboard/__manifest__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
'name': 'EnCoach Entity Onboarding',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Entity student CSV bulk upload, account creation, credential management',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import entity_onboard
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
@@ -0,0 +1,2 @@
|
||||
from . import csv_parser
|
||||
from . import credential_service
|
||||
@@ -0,0 +1,72 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CredentialService:
|
||||
|
||||
def create_accounts(self, env, valid_rows, entity_id):
|
||||
"""Create res.users records from validated CSV rows.
|
||||
|
||||
Each user gets password=national_id, account_source='entity_bulk_upload',
|
||||
and account_status='activated'.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
valid_rows: list of dicts with keys name, email, national_id, phone.
|
||||
entity_id: int, the entity that owns these students.
|
||||
|
||||
Returns:
|
||||
recordset of created res.users.
|
||||
"""
|
||||
User = env['res.users']
|
||||
created_users = User
|
||||
for row in valid_rows:
|
||||
try:
|
||||
user = User.sudo().create({
|
||||
'name': row['name'],
|
||||
'login': row['email'],
|
||||
'email': row['email'],
|
||||
'phone': row.get('phone', ''),
|
||||
'password': row['national_id'],
|
||||
'account_source': 'entity_bulk_upload',
|
||||
'account_status': 'activated',
|
||||
'entity_id': entity_id,
|
||||
})
|
||||
created_users |= user
|
||||
except Exception:
|
||||
_logger.exception("Failed to create account for %s", row.get('email'))
|
||||
return created_users
|
||||
|
||||
def send_credentials(self, env, user_ids):
|
||||
"""Send credential emails to newly created users.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
user_ids: list of int, res.users IDs.
|
||||
"""
|
||||
Mail = env['mail.mail']
|
||||
for user in env['res.users'].sudo().browse(user_ids):
|
||||
mail_values = {
|
||||
'subject': 'Your EnCoach Account Credentials',
|
||||
'email_from': env.company.email or 'noreply@encoach.com',
|
||||
'email_to': user.email,
|
||||
'body_html': (
|
||||
f'<p>Dear {user.name},</p>'
|
||||
f'<p>Your EnCoach account has been created.</p>'
|
||||
f'<p>Login: {user.login}</p>'
|
||||
f'<p>Password: Your National ID</p>'
|
||||
f'<p>Please change your password after first login.</p>'
|
||||
),
|
||||
}
|
||||
mail = Mail.sudo().create(mail_values)
|
||||
mail.send()
|
||||
|
||||
def resend_credentials(self, env, user_id):
|
||||
"""Resend credential email for a single user.
|
||||
|
||||
Args:
|
||||
env: Odoo environment.
|
||||
user_id: int, res.users ID.
|
||||
"""
|
||||
self.send_credentials(env, [user_id])
|
||||
69
custom_addons/encoach_entity_onboard/services/csv_parser.py
Normal file
69
custom_addons/encoach_entity_onboard/services/csv_parser.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
|
||||
REQUIRED_COLUMNS = {'name', 'email', 'national_id', 'phone'}
|
||||
|
||||
|
||||
class CsvParser:
|
||||
|
||||
def validate(self, file_content):
|
||||
"""Parse CSV content and validate required columns.
|
||||
|
||||
Args:
|
||||
file_content: raw CSV string or bytes.
|
||||
|
||||
Returns:
|
||||
dict with 'valid_rows' (list of dicts) and 'errors' (list of str).
|
||||
"""
|
||||
if isinstance(file_content, bytes):
|
||||
file_content = file_content.decode('utf-8-sig')
|
||||
|
||||
errors = []
|
||||
valid_rows = []
|
||||
|
||||
reader = csv.DictReader(io.StringIO(file_content))
|
||||
|
||||
if not reader.fieldnames:
|
||||
return {'valid_rows': [], 'errors': ['Empty CSV file or missing header row']}
|
||||
|
||||
header_set = {col.strip().lower() for col in reader.fieldnames}
|
||||
missing = REQUIRED_COLUMNS - header_set
|
||||
if missing:
|
||||
return {
|
||||
'valid_rows': [],
|
||||
'errors': [f"Missing required columns: {', '.join(sorted(missing))}"],
|
||||
}
|
||||
|
||||
for idx, row in enumerate(reader, start=2):
|
||||
parsed = self.parse_row(row)
|
||||
row_errors = []
|
||||
|
||||
if not parsed.get('name'):
|
||||
row_errors.append(f"Row {idx}: 'name' is required")
|
||||
if not parsed.get('email'):
|
||||
row_errors.append(f"Row {idx}: 'email' is required")
|
||||
if not parsed.get('national_id'):
|
||||
row_errors.append(f"Row {idx}: 'national_id' is required")
|
||||
|
||||
if row_errors:
|
||||
errors.extend(row_errors)
|
||||
else:
|
||||
valid_rows.append(parsed)
|
||||
|
||||
return {'valid_rows': valid_rows, 'errors': errors}
|
||||
|
||||
def parse_row(self, row):
|
||||
"""Normalize a single CSV row.
|
||||
|
||||
Args:
|
||||
row: dict from csv.DictReader.
|
||||
|
||||
Returns:
|
||||
dict with trimmed, normalized values.
|
||||
"""
|
||||
normalized = {}
|
||||
for key, value in row.items():
|
||||
clean_key = key.strip().lower()
|
||||
normalized[clean_key] = value.strip() if value else ''
|
||||
return normalized
|
||||
Reference in New Issue
Block a user