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:
@@ -4,3 +4,4 @@ from . import media_controller
|
||||
from . import prompt_controller
|
||||
from . import feedback_controller
|
||||
from . import agents_controller
|
||||
from . import ai_settings_controller
|
||||
|
||||
292
custom_addons/encoach_ai/controllers/ai_settings_controller.py
Normal file
292
custom_addons/encoach_ai/controllers/ai_settings_controller.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Admin endpoints for AI provider selection and API-key management.
|
||||
|
||||
* ``GET /api/ai/settings/providers`` — current provider per capability,
|
||||
redacted view of which API keys are present (booleans only — keys are
|
||||
*never* echoed back), and the list of allowed providers per capability.
|
||||
|
||||
* ``PATCH /api/ai/settings/providers`` — write provider choices and/or
|
||||
API keys to ``ir.config_parameter``. Settings take effect on the very
|
||||
next request (no caching), so admins can flip providers without an
|
||||
Odoo restart.
|
||||
|
||||
The controller is admin-gated:
|
||||
|
||||
* The caller must be authenticated (``@jwt_required``).
|
||||
* The caller must have ``user_type == 'admin'`` *or* be in the
|
||||
``base.group_system`` group. Anything else returns 403.
|
||||
|
||||
API-key fields are write-only over the wire. Sending an empty string
|
||||
clears the key; omitting the field leaves it unchanged. The GET response
|
||||
returns ``{"openai_key_set": true | false, ...}`` markers so the UI can
|
||||
render a "saved · click to replace" state without ever leaking the
|
||||
secret value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body,
|
||||
)
|
||||
from odoo.addons.encoach_ai.services import provider_router
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Provider keys per capability — mirrors provider_router.CAPABILITIES but
|
||||
# adds UI labels and the "kind" so the frontend can render appropriate
|
||||
# icons / disclaimers.
|
||||
_PROVIDER_OPTIONS = {
|
||||
'text': [
|
||||
{'value': 'openai', 'label': 'OpenAI (GPT-4o)', 'kind': 'paid'},
|
||||
{'value': 'mock', 'label': 'Mock (deterministic stub)', 'kind': 'free'},
|
||||
],
|
||||
'image': [
|
||||
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||
{'value': 'openai', 'label': 'OpenAI (DALL-E 3)', 'kind': 'paid'},
|
||||
{'value': 'pillow', 'label': 'Pillow placeholder (offline)', 'kind': 'free'},
|
||||
{'value': 'unsplash', 'label': 'Unsplash Source (free, network)', 'kind': 'free'},
|
||||
{'value': 'mock', 'label': 'Mock card', 'kind': 'free'},
|
||||
],
|
||||
'audio': [
|
||||
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||
{'value': 'polly', 'label': 'AWS Polly (neural)', 'kind': 'paid'},
|
||||
{'value': 'elevenlabs', 'label': 'ElevenLabs (multilingual)', 'kind': 'paid'},
|
||||
{'value': 'gtts', 'label': 'gTTS (free, network)', 'kind': 'free'},
|
||||
{'value': 'silent', 'label': 'Silent stub (offline)', 'kind': 'free'},
|
||||
],
|
||||
'video': [
|
||||
{'value': 'auto', 'label': 'Auto', 'kind': 'auto'},
|
||||
{'value': 'ffmpeg', 'label': 'ffmpeg slideshow (image+audio)', 'kind': 'free'},
|
||||
{'value': 'static', 'label': 'Static placeholder image', 'kind': 'free'},
|
||||
],
|
||||
}
|
||||
|
||||
# API-key params managed by this endpoint. Keys are write-only — the
|
||||
# response only ever returns ``<name>_set: bool``.
|
||||
_KEY_PARAMS = {
|
||||
'openai_api_key': 'encoach_ai.openai_api_key',
|
||||
'aws_access_key': 'encoach_ai.aws_access_key',
|
||||
'aws_secret_key': 'encoach_ai.aws_secret_key',
|
||||
'aws_region': 'encoach_ai.aws_region',
|
||||
'elevenlabs_api_key': 'encoach_ai.elevenlabs_api_key',
|
||||
'gptzero_api_key': 'encoach_ai.gptzero_api_key',
|
||||
# Paymob (payments) — included so all platform secrets live in one UI
|
||||
'paymob_api_key': 'encoach.paymob.api_key',
|
||||
'paymob_integration_id': 'encoach.paymob.integration_id',
|
||||
'paymob_iframe_id': 'encoach.paymob.iframe_id',
|
||||
'paymob_hmac_secret': 'encoach.paymob.hmac_secret',
|
||||
}
|
||||
|
||||
# These params don't carry secrets so we expose their plaintext values.
|
||||
_PLAIN_PARAMS = {
|
||||
'aws_region': 'encoach_ai.aws_region',
|
||||
'openai_model': 'encoach_ai.openai_model',
|
||||
'openai_fast_model': 'encoach_ai.openai_fast_model',
|
||||
'elevenlabs_model': 'encoach_ai.elevenlabs_model',
|
||||
'request_timeout': 'encoach_ai.request_timeout',
|
||||
'max_retries': 'encoach_ai.max_retries',
|
||||
'enabled': 'encoach_ai.enabled',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_admin(env):
|
||||
"""Return True if the calling user is an EnCoach admin or system admin."""
|
||||
user = env.user
|
||||
if not user or not user.id:
|
||||
return False
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
user_type = getattr(user, 'user_type', None)
|
||||
return user_type == 'admin'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_state(env):
|
||||
"""Build the full settings payload (no secrets in the output)."""
|
||||
Param = env['ir.config_parameter'].sudo()
|
||||
providers = {}
|
||||
for cap, options in _PROVIDER_OPTIONS.items():
|
||||
providers[cap] = {
|
||||
'active': provider_router.get_active_provider(env, cap),
|
||||
'options': options,
|
||||
'paid_with_credentials': provider_router.get_paid_provider_keys(
|
||||
env, cap,
|
||||
),
|
||||
}
|
||||
keys_set = {}
|
||||
for short, full_param in _KEY_PARAMS.items():
|
||||
# ``aws_region`` happens to live in both maps — it's not a secret,
|
||||
# so we surface its value in ``plain`` and *also* mark it as set so
|
||||
# the UI can show the field consistently.
|
||||
val = Param.get_param(full_param)
|
||||
keys_set[short] = bool(val)
|
||||
plain = {short: Param.get_param(p, '')
|
||||
for short, p in _PLAIN_PARAMS.items()}
|
||||
return {
|
||||
'providers': providers,
|
||||
'keys_set': keys_set,
|
||||
'plain': plain,
|
||||
}
|
||||
|
||||
|
||||
def _is_clear_marker(value):
|
||||
"""A trimmed, empty string explicitly clears the param."""
|
||||
return isinstance(value, str) and value.strip() == ''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Controller
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AISettingsController(http.Controller):
|
||||
"""REST surface for the AI Provider Settings admin page."""
|
||||
|
||||
@http.route('/api/ai/settings/providers',
|
||||
type='http', auth='none', methods=['GET', 'OPTIONS'],
|
||||
csrf=False)
|
||||
@jwt_required
|
||||
def get_providers(self, **kw):
|
||||
if not _is_admin(request.env):
|
||||
return _error_response('Admin access required', 403)
|
||||
try:
|
||||
return _json_response({'data': _read_state(request.env)})
|
||||
except Exception as exc:
|
||||
_logger.exception('ai_settings.get failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@http.route('/api/ai/settings/providers',
|
||||
type='http', auth='none', methods=['PATCH', 'POST', 'PUT'],
|
||||
csrf=False)
|
||||
@jwt_required
|
||||
def patch_providers(self, **kw):
|
||||
if not _is_admin(request.env):
|
||||
return _error_response('Admin access required', 403)
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
Param = request.env['ir.config_parameter'].sudo()
|
||||
|
||||
# 1. Update active provider per capability — validate that the
|
||||
# chosen value is one of the offered options to keep junk
|
||||
# out of ir.config_parameter.
|
||||
provider_updates = body.get('providers') or {}
|
||||
invalid = []
|
||||
for cap, value in provider_updates.items():
|
||||
if cap not in _PROVIDER_OPTIONS:
|
||||
invalid.append(f'unknown capability: {cap}')
|
||||
continue
|
||||
allowed = {o['value'] for o in _PROVIDER_OPTIONS[cap]}
|
||||
if value not in allowed:
|
||||
invalid.append(f'{cap}: {value!r} not in {sorted(allowed)}')
|
||||
continue
|
||||
Param.set_param(provider_router.CAPABILITIES[cap]['param'], value)
|
||||
if invalid:
|
||||
return _error_response(
|
||||
'Invalid provider selections: ' + '; '.join(invalid), 400,
|
||||
)
|
||||
|
||||
# 2. Update API keys — write-only. An empty string clears the
|
||||
# param; omitting the field leaves it untouched.
|
||||
key_updates = body.get('keys') or {}
|
||||
for short, value in key_updates.items():
|
||||
if short not in _KEY_PARAMS:
|
||||
continue
|
||||
full_param = _KEY_PARAMS[short]
|
||||
if value is None:
|
||||
continue
|
||||
if _is_clear_marker(value):
|
||||
Param.set_param(full_param, '')
|
||||
else:
|
||||
# Trim whitespace to defend against a copy-paste with
|
||||
# a trailing newline that would silently break SDK auth.
|
||||
Param.set_param(full_param, str(value).strip())
|
||||
|
||||
# 3. Plain-value updates (model names, region, timeout, ...).
|
||||
plain_updates = body.get('plain') or {}
|
||||
for short, value in plain_updates.items():
|
||||
if short not in _PLAIN_PARAMS:
|
||||
continue
|
||||
Param.set_param(_PLAIN_PARAMS[short], '' if value is None
|
||||
else str(value))
|
||||
|
||||
# Audit log so admins can see who flipped providers.
|
||||
try:
|
||||
_logger.info(
|
||||
'ai_settings.update by user_id=%s providers=%s '
|
||||
'keys_changed=%s plain_changed=%s',
|
||||
request.env.user.id,
|
||||
list(provider_updates.keys()),
|
||||
[k for k in key_updates.keys() if k in _KEY_PARAMS],
|
||||
list(plain_updates.keys()),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _json_response({'data': _read_state(request.env)})
|
||||
except Exception as exc:
|
||||
_logger.exception('ai_settings.patch failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@http.route('/api/ai/settings/providers/test',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def test_provider(self, **kw):
|
||||
"""Quick "is this configured?" probe for the UI's Test button.
|
||||
|
||||
Body: ``{"capability": "image" | "audio" | "text"}``.
|
||||
|
||||
Returns the resolved provider chain plus a flag for each entry
|
||||
indicating whether credentials are present. We deliberately do
|
||||
NOT make a real network call — we just resolve the chain and
|
||||
check for credentials so the test is instant and free.
|
||||
"""
|
||||
if not _is_admin(request.env):
|
||||
return _error_response('Admin access required', 403)
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
capability = body.get('capability') or 'image'
|
||||
if capability not in provider_router.CAPABILITIES:
|
||||
return _error_response(
|
||||
f'Unknown capability: {capability}', 400,
|
||||
)
|
||||
chain = provider_router.resolve_chain(request.env, capability)
|
||||
paid_with_creds = set(
|
||||
provider_router.get_paid_provider_keys(request.env, capability),
|
||||
)
|
||||
entries = []
|
||||
for prov in chain:
|
||||
if prov in provider_router.CAPABILITIES[capability]['paid']:
|
||||
ok = prov in paid_with_creds
|
||||
note = 'credentials configured' if ok else 'no API key'
|
||||
else:
|
||||
ok = True # free providers are always available
|
||||
note = 'free fallback'
|
||||
entries.append({'provider': prov, 'ok': ok, 'note': note})
|
||||
return _json_response({
|
||||
'capability': capability,
|
||||
'active': provider_router.get_active_provider(
|
||||
request.env, capability),
|
||||
'chain': entries,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('ai_settings.test failed')
|
||||
return _error_response(str(exc), 500)
|
||||
Reference in New Issue
Block a user