feat(platform): ship AI fallback stack and entity-scoped course planning

Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-26 02:34:52 +04:00
parent afd1662a60
commit cd47d01f53
26 changed files with 2795 additions and 338 deletions

View File

@@ -304,8 +304,23 @@ class ApprovalWorkflowController(http.Controller):
if not req_rec.exists():
return _error_response('Request not found', 404)
# Authorization — only the user assigned to the current stage
# (or a system admin) may approve. Without this check any
# authenticated user could ride a valid JWT and approve any
# request, bypassing the entire approval workflow.
current_user = request.env.user
stage = req_rec.current_stage_id
assigned = stage.approver_id if stage else None
is_admin = (
current_user.has_group('base.group_system')
or getattr(current_user, 'user_type', None) == 'admin'
)
if assigned and assigned.id != current_user.id and not is_admin:
return _error_response(
'You are not the assigned approver for this stage', 403,
)
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
@@ -362,8 +377,20 @@ class ApprovalWorkflowController(http.Controller):
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
# Same authorization gate as approve — the rejection action
# is just as sensitive as the approval action.
current_user = request.env.user
stage = req_rec.current_stage_id
assigned = stage.approver_id if stage else None
is_admin = (
current_user.has_group('base.group_system')
or getattr(current_user, 'user_type', None) == 'admin'
)
if assigned and assigned.id != current_user.id and not is_admin:
return _error_response(
'You are not the assigned approver for this stage', 403,
)
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'rejected',

View File

@@ -11,6 +11,18 @@ _logger = logging.getLogger(__name__)
ENTITY_MODEL = 'encoach.entity'
def _ensure_admin_user():
user = request.env.user.sudo()
is_admin = bool(
user.has_group('base.group_system')
or user.has_group('base.group_erp_manager')
or getattr(user, 'user_type', '') == 'admin'
)
if not is_admin:
raise PermissionError('Admin access required')
return user
def _entity_to_dict(entity):
d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else {
'id': entity.id,
@@ -35,6 +47,16 @@ def _role_to_dict(role):
}
def _user_to_dict(user):
return {
'id': user.id,
'name': user.name or '',
'login': user.login or '',
'email': user.email or '',
'active': bool(user.active),
}
class EntityController(http.Controller):
@http.route('/api/entities', type='http', auth='public',
@@ -59,6 +81,62 @@ class EntityController(http.Controller):
_logger.exception('list entities failed')
return _error_response(str(e), 500)
@http.route('/api/entities/<int:entity_id>/users', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_entity_users(self, entity_id, **kw):
try:
_ensure_admin_user()
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
items = [_user_to_dict(u) for u in entity.user_ids.sorted('name')]
return _json_response({
'items': items,
'data': items,
'total': len(items),
'entity_id': entity.id,
})
except Exception as e:
code = 403 if isinstance(e, PermissionError) else 500
return _error_response(str(e), code)
@http.route('/api/entities/<int:entity_id>/users', type='http', auth='public',
methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_entity_users(self, entity_id, **kw):
try:
_ensure_admin_user()
body = _get_json_body() or {}
raw_ids = body.get('user_ids') or []
if not isinstance(raw_ids, list):
return _error_response('user_ids must be a list', 400)
try:
user_ids = [int(uid) for uid in raw_ids if uid is not None]
except (TypeError, ValueError):
return _error_response('user_ids must contain integers', 400)
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
users = request.env['res.users'].sudo().browse(user_ids).exists()
if len(users) != len(user_ids):
return _error_response('Some users do not exist', 400)
entity.write({'user_ids': [(6, 0, users.ids)]})
updated = [_user_to_dict(u) for u in entity.user_ids.sorted('name')]
return _json_response({
'success': True,
'entity_id': entity.id,
'user_ids': entity.user_ids.ids,
'items': updated,
'total': len(updated),
})
except Exception as e:
code = 403 if isinstance(e, PermissionError) else 500
return _error_response(str(e), code)
@http.route('/api/entities/<int:entity_id>', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required