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:
@@ -18,6 +18,10 @@
|
||||
"depends": ["base", "encoach_core", "encoach_api"],
|
||||
"external_dependencies": {
|
||||
"python": ["openai", "boto3", "langgraph", "langchain_core"],
|
||||
# Soft deps used only by free media fallbacks; the platform still
|
||||
# boots and works fine without them — see services/free_image.py
|
||||
# and services/free_tts.py for graceful import-failure handling.
|
||||
# Add to a real requirements file: ``pip install Pillow gTTS``.
|
||||
},
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -9,3 +9,6 @@ from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9)
|
||||
from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1)
|
||||
from . import agent_tools # registry of tool handlers used by AgentRuntime
|
||||
from .agent_runtime import AgentRuntime # LangGraph-backed core agent runtime
|
||||
from . import provider_router # capability -> active-provider resolver
|
||||
from . import free_image # offline Pillow-based image placeholder
|
||||
from . import free_tts # gTTS + silent-MP3 audio fallbacks
|
||||
|
||||
@@ -78,6 +78,7 @@ class AgentState(TypedDict, total=False):
|
||||
retrieval: list[dict] # hits from the retrieval node (RAG)
|
||||
iterations: int # guard against runaway ReAct loops
|
||||
error: str # populated on fatal failure
|
||||
should_revise: bool # set by review node when a revise pass is queued
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -92,6 +93,13 @@ class AgentRuntime:
|
||||
# Factories
|
||||
# ------------------------------------------------------------------
|
||||
def __init__(self, env, agent, *, language: str | None = None):
|
||||
# INVARIANT: every AgentRuntime is per-request and constructs a
|
||||
# fresh OpenAIService, which reads ir.config_parameter on every
|
||||
# __init__. Combined with MediaService → provider_router (also
|
||||
# uncached), this guarantees that flipping a provider in the
|
||||
# admin UI takes effect on the very next request — no Odoo
|
||||
# restart, no cache invalidation. Don't introduce any
|
||||
# module-level or class-level provider caches here.
|
||||
self.env = env
|
||||
self.agent = agent
|
||||
self.language = language
|
||||
@@ -323,7 +331,21 @@ class AgentRuntime:
|
||||
return {**state, "messages": messages, "retrieval": items}
|
||||
|
||||
def _node_review(self, state: AgentState) -> AgentState:
|
||||
"""Run every configured quality tool against the LLM's output."""
|
||||
"""Run quality tools against the LLM output and (maybe) queue a revision.
|
||||
|
||||
We do all state mutations here — adding the critique message and
|
||||
bumping ``revisions_used`` — and only signal the router with a
|
||||
boolean ``should_revise``. LangGraph routing functions are
|
||||
treated as pure: any state changes made there are discarded, so
|
||||
keeping the router pure prevents an infinite revise loop where
|
||||
the counter never actually increments.
|
||||
"""
|
||||
# If the LLM step already errored out, don't waste a quality
|
||||
# check on the empty/garbage output and don't try to "revise" —
|
||||
# another LLM call would just hit the same permanent failure.
|
||||
if state.get("error"):
|
||||
return {**state, "quality_issues": [], "should_revise": False}
|
||||
|
||||
text = state.get("output_raw") or ""
|
||||
if isinstance(state.get("output"), dict):
|
||||
# Flatten the dict to text so the quality tools see something
|
||||
@@ -348,29 +370,38 @@ class AgentRuntime:
|
||||
})
|
||||
if res.get("ok") is False:
|
||||
issues.extend(res.get("issues") or [res.get("error") or key])
|
||||
return {**state, "quality_issues": issues}
|
||||
|
||||
revisions_used = state.get("revisions_used") or 0
|
||||
max_rev = max(0, int(self.agent.max_revisions or 0))
|
||||
if issues and revisions_used < max_rev:
|
||||
critique = (
|
||||
"Your previous draft was rejected for the following reasons:\n- "
|
||||
+ "\n- ".join(issues)
|
||||
+ "\n\nProduce an improved version that addresses every issue. "
|
||||
"Keep the same JSON schema if one was requested."
|
||||
)
|
||||
messages = list(state.get("messages") or []) + [
|
||||
{"role": "system", "content": critique}
|
||||
]
|
||||
return {
|
||||
**state,
|
||||
"messages": messages,
|
||||
"quality_issues": issues,
|
||||
"revisions_used": revisions_used + 1,
|
||||
"should_revise": True,
|
||||
}
|
||||
return {
|
||||
**state,
|
||||
"quality_issues": issues,
|
||||
"should_revise": False,
|
||||
}
|
||||
|
||||
def _route_after_review(self, state: AgentState) -> str:
|
||||
issues = state.get("quality_issues") or []
|
||||
if not issues:
|
||||
# Pure router: only inspect state, never mutate. The decision
|
||||
# was prepared in ``_node_review``.
|
||||
if state.get("error"):
|
||||
return "done"
|
||||
if (state.get("revisions_used") or 0) >= max(0, self.agent.max_revisions):
|
||||
return "done"
|
||||
# Queue up a revision: add a system message with the critique and
|
||||
# bump the counter. We return via "revise" which loops back to
|
||||
# the LLM node.
|
||||
critique = (
|
||||
"Your previous draft was rejected for the following reasons:\n- "
|
||||
+ "\n- ".join(issues)
|
||||
+ "\n\nProduce an improved version that addresses every issue. "
|
||||
"Keep the same JSON schema if one was requested."
|
||||
)
|
||||
messages = list(state.get("messages") or []) + [
|
||||
{"role": "system", "content": critique}
|
||||
]
|
||||
state["messages"] = messages
|
||||
state["revisions_used"] = (state.get("revisions_used") or 0) + 1
|
||||
return "revise"
|
||||
return "revise" if state.get("should_revise") else "done"
|
||||
|
||||
# ReAct / tool-calling -------------------------------------------------
|
||||
def _node_llm_tools(self, state: AgentState) -> AgentState:
|
||||
|
||||
150
backend/custom_addons/encoach_ai/services/free_image.py
Normal file
150
backend/custom_addons/encoach_ai/services/free_image.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Offline image placeholder generator using Pillow.
|
||||
|
||||
Used as a free fallback when DALL-E (or any paid image API) is missing
|
||||
credentials, returns a billing/quota error, or is otherwise unavailable.
|
||||
The resulting PNG is a clean education-themed gradient card with the
|
||||
title overlaid — good enough to keep the LMS UI populated until a real
|
||||
image is generated.
|
||||
|
||||
Pillow is the only runtime dependency (already a transitive dep of Odoo
|
||||
through ``reportlab`` on most installs). If Pillow is not importable we
|
||||
raise so the caller knows to skip this provider and try the next one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import random
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError: # pragma: no cover — Pillow is a soft dep
|
||||
Image = None
|
||||
ImageDraw = None
|
||||
ImageFont = None
|
||||
|
||||
|
||||
# Soft education palettes (top-left, bottom-right gradient pairs)
|
||||
_PALETTES = [
|
||||
((59, 130, 246), (147, 197, 253)), # blue
|
||||
((16, 185, 129), (110, 231, 183)), # emerald
|
||||
((245, 158, 11), (252, 211, 77)), # amber
|
||||
((139, 92, 246), (196, 181, 253)), # violet
|
||||
((236, 72, 153), (249, 168, 212)), # pink
|
||||
((20, 184, 166), (153, 246, 228)), # teal
|
||||
((99, 102, 241), (165, 180, 252)), # indigo
|
||||
((220, 38, 38), (252, 165, 165)), # rose
|
||||
]
|
||||
|
||||
|
||||
def _parse_size(size):
|
||||
try:
|
||||
w, h = (int(p) for p in str(size).lower().split('x'))
|
||||
except Exception:
|
||||
return 1024, 1024
|
||||
return max(64, min(2048, w)), max(64, min(2048, h))
|
||||
|
||||
|
||||
def _wrap(draw, text, font, max_width, max_lines=6):
|
||||
words = (text or '').split()
|
||||
lines, current = [], ''
|
||||
for word in words:
|
||||
candidate = (current + ' ' + word).strip()
|
||||
if draw.textlength(candidate, font=font) <= max_width:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if len(lines) >= max_lines:
|
||||
return lines
|
||||
if current and len(lines) < max_lines:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
def _load_font(preferred, size):
|
||||
"""Try a list of fonts; fall back to the bundled default at any size."""
|
||||
for name in preferred:
|
||||
try:
|
||||
return ImageFont.truetype(name, size=size)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def render_placeholder(title, *, subtitle=None, size='1024x1024', seed=None):
|
||||
"""Return PNG bytes for a placeholder card.
|
||||
|
||||
Args:
|
||||
title: Main title text rendered large and centred.
|
||||
subtitle: Optional smaller line below the title (e.g. CEFR level,
|
||||
week label) — pass ``None`` to skip.
|
||||
size: ``"WIDTHxHEIGHT"`` string, e.g. ``"1024x1024"``.
|
||||
seed: Optional seed for palette selection so the same title yields
|
||||
the same gradient repeatably.
|
||||
"""
|
||||
if Image is None:
|
||||
raise RuntimeError(
|
||||
'Pillow not installed — pip install Pillow to enable the free '
|
||||
'image fallback.'
|
||||
)
|
||||
w, h = _parse_size(size)
|
||||
rng = random.Random(seed if seed is not None else (title or '').lower())
|
||||
top, bottom = rng.choice(_PALETTES)
|
||||
|
||||
img = Image.new('RGB', (w, h), top)
|
||||
px = img.load()
|
||||
for y in range(h):
|
||||
t = y / max(1, h - 1)
|
||||
r = int(top[0] * (1 - t) + bottom[0] * t)
|
||||
g = int(top[1] * (1 - t) + bottom[1] * t)
|
||||
b = int(top[2] * (1 - t) + bottom[2] * t)
|
||||
for x in range(w):
|
||||
px[x, y] = (r, g, b)
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
title_font = _load_font(
|
||||
['DejaVuSans-Bold.ttf', 'Arial Bold.ttf', 'arial.ttf'],
|
||||
int(h * 0.07),
|
||||
)
|
||||
sub_font = _load_font(
|
||||
['DejaVuSans.ttf', 'Arial.ttf', 'arial.ttf'],
|
||||
int(h * 0.035),
|
||||
)
|
||||
|
||||
margin = int(w * 0.08)
|
||||
max_text_w = w - 2 * margin
|
||||
title_lines = _wrap(draw, title or 'Untitled', title_font, max_text_w)
|
||||
line_h = int(h * 0.085)
|
||||
total_h = line_h * len(title_lines)
|
||||
y = (h - total_h) // 2
|
||||
for line in title_lines:
|
||||
line_w = draw.textlength(line, font=title_font)
|
||||
x = (w - line_w) // 2
|
||||
draw.text((x + 2, y + 2), line, font=title_font, fill=(0, 0, 0))
|
||||
draw.text((x, y), line, font=title_font, fill=(255, 255, 255))
|
||||
y += line_h
|
||||
|
||||
if subtitle:
|
||||
sub_w = draw.textlength(subtitle, font=sub_font)
|
||||
sx = (w - sub_w) // 2
|
||||
sy = y + int(h * 0.02)
|
||||
draw.text((sx + 1, sy + 1), subtitle, font=sub_font, fill=(0, 0, 0))
|
||||
draw.text((sx, sy), subtitle, font=sub_font, fill=(255, 255, 255))
|
||||
|
||||
# Subtle EnCoach watermark badge so generated assets are easy to spot
|
||||
badge_font = _load_font(['DejaVuSans.ttf', 'Arial.ttf'], int(h * 0.022))
|
||||
badge = 'EnCoach · placeholder'
|
||||
bw = draw.textlength(badge, font=badge_font)
|
||||
draw.text(
|
||||
(w - margin - bw, h - margin),
|
||||
badge, font=badge_font, fill=(255, 255, 255),
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG', optimize=True)
|
||||
return buf.getvalue()
|
||||
128
backend/custom_addons/encoach_ai/services/free_tts.py
Normal file
128
backend/custom_addons/encoach_ai/services/free_tts.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Free / offline text-to-speech fallbacks.
|
||||
|
||||
Two providers, ordered most-useful-first:
|
||||
|
||||
1. ``gtts`` — Google Translate TTS. Free and surprisingly natural, but
|
||||
requires outbound network access. We try this first when
|
||||
the paid provider is exhausted.
|
||||
|
||||
2. ``silent`` — A pre-encoded silent MP3 (~1 second). Used as a last
|
||||
resort so that downstream consumers (notably the video
|
||||
composer) still receive valid audio bytes and don't crash.
|
||||
|
||||
The shape of the return dict matches what ``PollyService.synthesize``
|
||||
returns (``audio``, ``content_type``, ``voice``, ``characters``) so
|
||||
callers can swap providers transparently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try: # pragma: no cover — gTTS is a soft dep
|
||||
from gtts import gTTS
|
||||
except ImportError:
|
||||
gTTS = None
|
||||
|
||||
|
||||
def _build_silent_wav(duration_seconds: float = 1.0,
|
||||
sample_rate: int = 8000) -> bytes:
|
||||
"""Construct a valid PCM WAV byte string with all-zero samples.
|
||||
|
||||
WAV is trivially constructable from primitives so we can produce it
|
||||
without any external library. ffmpeg accepts WAV as readily as MP3,
|
||||
so the rest of the pipeline is unaffected by the format choice.
|
||||
"""
|
||||
n_samples = max(1, int(duration_seconds * sample_rate))
|
||||
samples = b'\x00\x00' * n_samples # 16-bit mono silence
|
||||
data_size = len(samples)
|
||||
fmt_chunk = (
|
||||
b'fmt '
|
||||
+ struct.pack('<I', 16) # PCM fmt chunk size
|
||||
+ struct.pack('<H', 1) # PCM
|
||||
+ struct.pack('<H', 1) # mono
|
||||
+ struct.pack('<I', sample_rate)
|
||||
+ struct.pack('<I', sample_rate * 2) # byte rate
|
||||
+ struct.pack('<H', 2) # block align
|
||||
+ struct.pack('<H', 16) # bits per sample
|
||||
)
|
||||
data_chunk = b'data' + struct.pack('<I', data_size) + samples
|
||||
return (
|
||||
b'RIFF'
|
||||
+ struct.pack('<I', 36 + data_size)
|
||||
+ b'WAVE'
|
||||
+ fmt_chunk
|
||||
+ data_chunk
|
||||
)
|
||||
|
||||
|
||||
# Map our internal language codes to (gTTS lang, gTTS tld) tuples. The
|
||||
# tld controls accent (co.uk vs com vs com.au) so picking carefully here
|
||||
# gives the listening exam a more authentic accent.
|
||||
_GTTS_LANG_MAP = {
|
||||
'en-GB': ('en', 'co.uk'),
|
||||
'en-US': ('en', 'com'),
|
||||
'en-AU': ('en', 'com.au'),
|
||||
'en-IN': ('en', 'co.in'),
|
||||
'en': ('en', 'co.uk'),
|
||||
'ar': ('ar', 'com'),
|
||||
'ar-EG': ('ar', 'com'),
|
||||
'ar-SA': ('ar', 'com'),
|
||||
'fr': ('fr', 'fr'),
|
||||
'fr-FR': ('fr', 'fr'),
|
||||
'es': ('es', 'es'),
|
||||
'de': ('de', 'de'),
|
||||
'tr': ('tr', 'com'),
|
||||
'fa': ('fa', 'com'),
|
||||
'ur': ('ur', 'com'),
|
||||
'hi': ('hi', 'co.in'),
|
||||
'zh': ('zh-CN', 'com'),
|
||||
'ja': ('ja', 'com'),
|
||||
}
|
||||
|
||||
|
||||
def synthesize_with_gtts(text, *, language='en-GB'):
|
||||
"""Synthesize ``text`` to MP3 bytes using gTTS.
|
||||
|
||||
Raises ``RuntimeError`` if gTTS is not installed; the caller is
|
||||
expected to catch and try the next provider in the chain.
|
||||
"""
|
||||
if gTTS is None:
|
||||
raise RuntimeError(
|
||||
'gTTS not installed — pip install gTTS to enable the free '
|
||||
'audio fallback.'
|
||||
)
|
||||
short = (text or '')[:4500]
|
||||
if not short.strip():
|
||||
return synthesize_silent()
|
||||
lang, tld = _GTTS_LANG_MAP.get(language, ('en', 'co.uk'))
|
||||
buf = io.BytesIO()
|
||||
tts = gTTS(text=short, lang=lang, tld=tld, slow=False)
|
||||
tts.write_to_fp(buf)
|
||||
return {
|
||||
'audio': buf.getvalue(),
|
||||
'content_type': 'audio/mpeg',
|
||||
'voice': f'gtts-{lang}-{tld}',
|
||||
'characters': len(short),
|
||||
}
|
||||
|
||||
|
||||
def synthesize_silent(duration_seconds=1):
|
||||
"""Return a minimal valid silent audio stub.
|
||||
|
||||
Returns a PCM WAV (which ffmpeg accepts identically to MP3) of the
|
||||
requested duration. Used when even gTTS is unreachable and we just
|
||||
need *some* valid audio so the video composer doesn't fail and the
|
||||
media row can still be marked ``ready``.
|
||||
"""
|
||||
payload = _build_silent_wav(duration_seconds=duration_seconds)
|
||||
return {
|
||||
'audio': payload,
|
||||
'content_type': 'audio/wav',
|
||||
'voice': 'silent-stub',
|
||||
'characters': 0,
|
||||
}
|
||||
@@ -64,7 +64,18 @@ class OpenAIService:
|
||||
import os
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if _openai_mod and api_key:
|
||||
self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout)
|
||||
# The SDK retries internally up to 2 times by default with exponential
|
||||
# backoff, but we already do that ourselves in `_retry_with_backoff`.
|
||||
# Stacking both meant a single quota error could trigger 9+ retries
|
||||
# over several minutes before the controller could return — leaving
|
||||
# the frontend's `Generate plan` button hanging. We disable the
|
||||
# SDK's retries and let our own loop (which knows about
|
||||
# insufficient_quota) be the single source of truth.
|
||||
self.client = _openai_mod.OpenAI(
|
||||
api_key=api_key,
|
||||
timeout=self.request_timeout,
|
||||
max_retries=0,
|
||||
)
|
||||
else:
|
||||
self.client = None
|
||||
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
|
||||
@@ -114,6 +125,16 @@ class OpenAIService:
|
||||
return messages
|
||||
|
||||
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
|
||||
# Skip writing if the request transaction has already been
|
||||
# aborted/rolled back (typically when an upstream caller caught
|
||||
# the AI exception, re-raised, and the surrounding `try/except`
|
||||
# in our route handler is about to return 500). Trying to insert
|
||||
# in that state raises `psycopg2.InterfaceError: cursor already
|
||||
# closed` and pollutes the log with a misleading second stack
|
||||
# trace that hides the real upstream failure.
|
||||
cr = getattr(self.env, "cr", None)
|
||||
if cr is None or getattr(cr, "closed", False):
|
||||
return
|
||||
try:
|
||||
self.env["encoach.ai.log"].sudo().create({
|
||||
"service": "openai",
|
||||
@@ -128,15 +149,41 @@ class OpenAIService:
|
||||
"input_preview": (inp or "")[:500],
|
||||
"output_preview": (out or "")[:500],
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning("Failed to log AI call", exc_info=True)
|
||||
except Exception as exc:
|
||||
# Most common case is psycopg2.InterfaceError when the txn
|
||||
# has already been rolled back by a higher-level handler.
|
||||
# Don't include `exc_info=True` for that one — it's noise.
|
||||
err_str = str(exc).lower()
|
||||
if "cursor already closed" in err_str or "current transaction is aborted" in err_str:
|
||||
_logger.debug("Skipping AI log write — txn already aborted (%s)", action)
|
||||
else:
|
||||
_logger.warning("Failed to log AI call", exc_info=True)
|
||||
|
||||
def _check_enabled(self):
|
||||
if not self.enabled:
|
||||
raise RuntimeError("AI is disabled — enable in Settings > AI Configuration")
|
||||
|
||||
# Errors that will never resolve by retrying. These are user / billing
|
||||
# / configuration conditions: retrying just wastes wall-clock time and
|
||||
# leaves the frontend hanging on the wizard "Finish" button.
|
||||
_NON_RETRYABLE_MARKERS = (
|
||||
"insufficient_quota",
|
||||
"invalid_api_key",
|
||||
"incorrect_api_key",
|
||||
"account_deactivated",
|
||||
"billing_hard_limit_reached",
|
||||
"model_not_found",
|
||||
"context_length_exceeded",
|
||||
)
|
||||
|
||||
def _retry_with_backoff(self, fn, action, model):
|
||||
"""Execute fn with exponential backoff retries."""
|
||||
"""Execute ``fn`` with exponential backoff retries.
|
||||
|
||||
Permanent failures (quota exhausted, bad API key, etc.) are raised
|
||||
on the first attempt; transient ones (true rate-limit, 5xx) are
|
||||
retried up to ``self.max_retries``. The OpenAI SDK is configured
|
||||
with ``max_retries=0`` so this loop is the only retry layer.
|
||||
"""
|
||||
last_exc = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
@@ -144,6 +191,12 @@ class OpenAIService:
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
err_str = str(exc).lower()
|
||||
if any(m in err_str for m in self._NON_RETRYABLE_MARKERS):
|
||||
_logger.warning(
|
||||
"AI permanent failure for %s (no retry): %s",
|
||||
action, exc,
|
||||
)
|
||||
raise
|
||||
is_rate_limit = "rate" in err_str or "429" in err_str
|
||||
is_server_error = "500" in err_str or "502" in err_str or "503" in err_str
|
||||
if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1:
|
||||
|
||||
187
backend/custom_addons/encoach_ai/services/provider_router.py
Normal file
187
backend/custom_addons/encoach_ai/services/provider_router.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Resolve the active AI provider per capability.
|
||||
|
||||
Settings are read fresh from ``ir.config_parameter`` on every call — there
|
||||
is intentionally NO module-level cache so flipping a provider in the admin
|
||||
UI takes effect on the next request without restarting Odoo.
|
||||
|
||||
Public surface:
|
||||
|
||||
* :func:`get_active_provider(env, capability)` — returns the provider key
|
||||
configured for ``capability`` (one of ``text|image|audio|video``).
|
||||
* :func:`classify_provider_error(exc)` — turns an arbitrary exception from
|
||||
any third-party SDK into one of ``quota|auth|network|other`` so callers
|
||||
can decide whether to fall back automatically.
|
||||
* :class:`ProviderQuotaError`, :class:`ProviderAuthError` — typed errors
|
||||
that callers may raise when they want a strongly-typed signal.
|
||||
|
||||
The capability tables also enumerate the **allowed free providers** per
|
||||
capability — these are the ones the fallback chain uses when the paid
|
||||
provider returns a quota or auth error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capabilities & provider names
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ``auto`` means "pick the first paid provider that's configured, else fall
|
||||
# back to the first free provider that works". Admins who want to *force* a
|
||||
# specific provider should pick its name explicitly in the UI.
|
||||
|
||||
CAPABILITIES = {
|
||||
'text': {
|
||||
'param': 'encoach.ai.text_provider',
|
||||
'default': 'openai',
|
||||
'paid': ['openai'],
|
||||
'free': ['mock'],
|
||||
},
|
||||
'image': {
|
||||
'param': 'encoach.ai.image_provider',
|
||||
'default': 'auto',
|
||||
'paid': ['openai'],
|
||||
'free': ['pillow', 'unsplash', 'mock'],
|
||||
},
|
||||
'audio': {
|
||||
'param': 'encoach.ai.audio_provider',
|
||||
'default': 'auto',
|
||||
'paid': ['polly', 'elevenlabs'],
|
||||
'free': ['gtts', 'silent'],
|
||||
},
|
||||
'video': {
|
||||
'param': 'encoach.ai.video_provider',
|
||||
'default': 'auto',
|
||||
'paid': [],
|
||||
'free': ['ffmpeg', 'static'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typed errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProviderQuotaError(RuntimeError):
|
||||
"""Raised when a provider returns a billing/quota error.
|
||||
|
||||
Examples include OpenAI ``insufficient_quota``, AWS Polly
|
||||
``ThrottlingException``, ElevenLabs character-limit rejection, and any
|
||||
HTTP 402/429. Callers should treat this as a soft failure and try the
|
||||
next provider in the fallback chain.
|
||||
"""
|
||||
|
||||
|
||||
class ProviderAuthError(RuntimeError):
|
||||
"""Raised when a provider refuses authentication (missing/invalid key)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_active_provider(env, capability):
|
||||
"""Read the currently-active provider key for ``capability``.
|
||||
|
||||
Always reads from ``ir.config_parameter`` — never cached.
|
||||
"""
|
||||
cap = CAPABILITIES[capability]
|
||||
return env['ir.config_parameter'].sudo().get_param(
|
||||
cap['param'], cap['default'],
|
||||
) or cap['default']
|
||||
|
||||
|
||||
def get_paid_provider_keys(env, capability):
|
||||
"""Return the list of paid providers for the capability that have an
|
||||
API key configured. Used to decide whether to skip straight to free
|
||||
fallbacks when ``auto`` is selected but no paid keys exist.
|
||||
"""
|
||||
Param = env['ir.config_parameter'].sudo()
|
||||
available = []
|
||||
for provider in CAPABILITIES[capability]['paid']:
|
||||
if _provider_has_credentials(Param, provider):
|
||||
available.append(provider)
|
||||
return available
|
||||
|
||||
|
||||
def _provider_has_credentials(Param, provider):
|
||||
if provider == 'openai':
|
||||
return bool(Param.get_param('encoach_ai.openai_api_key'))
|
||||
if provider == 'polly':
|
||||
return bool(Param.get_param('encoach_ai.aws_access_key')) and bool(
|
||||
Param.get_param('encoach_ai.aws_secret_key')
|
||||
)
|
||||
if provider == 'elevenlabs':
|
||||
return bool(Param.get_param('encoach_ai.elevenlabs_api_key'))
|
||||
return False
|
||||
|
||||
|
||||
def resolve_chain(env, capability, *, requested=None):
|
||||
"""Return an ordered list of providers to try for ``capability``.
|
||||
|
||||
Args:
|
||||
capability: ``'text' | 'image' | 'audio' | 'video'``.
|
||||
requested: optional explicit provider override (e.g. body param).
|
||||
When supplied it goes to the front of the chain.
|
||||
"""
|
||||
cap = CAPABILITIES[capability]
|
||||
chain = []
|
||||
selected = (requested or get_active_provider(env, capability) or '').strip()
|
||||
|
||||
if selected and selected != 'auto':
|
||||
chain.append(selected)
|
||||
|
||||
# Paid providers with credentials, then free fallbacks
|
||||
if selected == 'auto' or not selected:
|
||||
for p in get_paid_provider_keys(env, capability):
|
||||
if p not in chain:
|
||||
chain.append(p)
|
||||
for p in cap['free']:
|
||||
if p not in chain:
|
||||
chain.append(p)
|
||||
return chain
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_QUOTA_TOKENS = (
|
||||
'insufficient_quota', 'quota', 'billing',
|
||||
'429', '402',
|
||||
'rate limit', 'rate_limit', 'rate-limit',
|
||||
'throttlingexception', 'thresholdexceeded',
|
||||
'limit_exceeded', 'character limit', 'too many requests',
|
||||
)
|
||||
_AUTH_TOKENS = (
|
||||
'invalid_api_key', 'incorrect api key', 'authentication',
|
||||
'unauthorized', 'access denied', '401', '403',
|
||||
'authenticationerror', 'permissiondenied', 'invalid api key',
|
||||
'missing api key',
|
||||
)
|
||||
|
||||
|
||||
def classify_provider_error(exc):
|
||||
"""Map any provider exception to one of: ``quota|auth|network|other``."""
|
||||
msg = (str(exc) or '').lower()
|
||||
name = type(exc).__name__.lower()
|
||||
blob = msg + ' ' + name
|
||||
if any(t in blob for t in _QUOTA_TOKENS):
|
||||
return 'quota'
|
||||
if any(t in blob for t in _AUTH_TOKENS):
|
||||
return 'auth'
|
||||
if 'timeout' in blob or 'connection' in blob or 'network' in msg:
|
||||
return 'network'
|
||||
return 'other'
|
||||
|
||||
|
||||
def should_fallback(exc):
|
||||
"""Whether the caller should try the next provider in the chain."""
|
||||
return classify_provider_error(exc) in ('quota', 'auth', 'network')
|
||||
@@ -7,12 +7,14 @@ decorator and returns JSON.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required,
|
||||
validate_token,
|
||||
_json_response,
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
@@ -42,7 +44,65 @@ def _request_language():
|
||||
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
|
||||
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for current JWT user."""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
return entity_ids, is_super
|
||||
|
||||
|
||||
def _default_entity_id_from_scope():
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if entity_ids:
|
||||
return entity_ids[0]
|
||||
if is_super:
|
||||
return False
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
|
||||
|
||||
def _ensure_entity_access(entity_id):
|
||||
if not entity_id:
|
||||
raise PermissionError('entity_id is required')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return int(entity_id)
|
||||
if not entity_ids:
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
if int(entity_id) not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
return int(entity_id)
|
||||
|
||||
|
||||
class CoursePlanController(http.Controller):
|
||||
def _plan_domain(self, extra=None):
|
||||
domain = list(extra or [])
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not entity_ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [('entity_id', 'in', entity_ids)]
|
||||
|
||||
def _assert_plan_access(self, plan):
|
||||
if not plan or not plan.exists():
|
||||
raise ValueError('Plan not found')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return
|
||||
if not plan.entity_id or plan.entity_id.id not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
|
||||
def _get_plan_scoped(self, plan_id):
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
self._assert_plan_access(plan)
|
||||
return plan
|
||||
|
||||
def _assert_material_access(self, material):
|
||||
if not material or not material.exists():
|
||||
raise ValueError('Material not found')
|
||||
self._assert_plan_access(material.plan_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan
|
||||
# ------------------------------------------------------------------
|
||||
@@ -54,15 +114,21 @@ class CoursePlanController(http.Controller):
|
||||
body = _get_json_body()
|
||||
if not (body.get('title') or '').strip():
|
||||
return _error_response('title is required', 400)
|
||||
if body.get('entity_id'):
|
||||
entity_id = _ensure_entity_access(int(body['entity_id']))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
plan = pipeline.generate_plan(body)
|
||||
if entity_id:
|
||||
plan.sudo().write({'entity_id': entity_id})
|
||||
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan
|
||||
@@ -73,10 +139,12 @@ class CoursePlanController(http.Controller):
|
||||
def list_plans(self, **kw):
|
||||
try:
|
||||
params = request.httprequest.args
|
||||
domain = []
|
||||
domain = self._plan_domain([])
|
||||
search = (params.get('search') or '').strip()
|
||||
if search:
|
||||
domain.append(('name', 'ilike', search))
|
||||
if params.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id')))))
|
||||
|
||||
Plan = request.env['encoach.course.plan'].sudo()
|
||||
offset, limit, page = _paginate({
|
||||
@@ -94,7 +162,7 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>
|
||||
@@ -104,15 +172,15 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def get_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.get failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/ai/course-plan/<id>
|
||||
@@ -122,14 +190,14 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def delete_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
plan.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -139,6 +207,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def generate_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
@@ -151,7 +220,7 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -161,6 +230,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
week = request.env['encoach.course.plan.week'].sudo().search([
|
||||
('plan_id', '=', int(plan_id)),
|
||||
('week_number', '=', int(week_number)),
|
||||
@@ -173,7 +243,7 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE A — Reference sources (RAG grounding)
|
||||
@@ -184,25 +254,23 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_sources(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [s.to_api_dict() for s in plan.source_ids],
|
||||
'count': len(plan.source_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_sources failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_source(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
ct = request.httprequest.content_type or ''
|
||||
files = request.httprequest.files
|
||||
@@ -250,37 +318,110 @@ class CoursePlanController(http.Controller):
|
||||
|
||||
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<plan_id>/sources/from-resources
|
||||
# ------------------------------------------------------------------
|
||||
# Attach one or more existing library resources (``encoach.resource``,
|
||||
# the items shown under /admin/resources) as RAG sources for a plan.
|
||||
#
|
||||
# Body shape: ``{ "resource_ids": [<int>, ...] }``. We dedupe against
|
||||
# already-linked resources so re-clicking "Attach" is a no-op rather
|
||||
# than producing duplicate index entries. Each new row auto-indexes
|
||||
# via the ``encoach.course.plan.source`` create() hook.
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/from-resources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def attach_library_resources(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
raw_ids = body.get('resource_ids') or body.get('ids') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return _error_response('resource_ids must be a list', 400)
|
||||
try:
|
||||
resource_ids = [int(x) for x in raw_ids if x is not None]
|
||||
except (TypeError, ValueError):
|
||||
return _error_response('resource_ids must contain integers', 400)
|
||||
if not resource_ids:
|
||||
return _error_response('No resource ids provided', 400)
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
Source = request.env['encoach.course.plan.source'].sudo()
|
||||
|
||||
already_linked = set(
|
||||
plan.source_ids.filtered('resource_id').mapped('resource_id.id')
|
||||
)
|
||||
attached, skipped, missing = [], [], []
|
||||
for rid in resource_ids:
|
||||
if rid in already_linked:
|
||||
skipped.append(rid)
|
||||
continue
|
||||
res = Resource.browse(rid)
|
||||
if not res.exists():
|
||||
missing.append(rid)
|
||||
continue
|
||||
rec = Source.create({
|
||||
'plan_id': plan.id,
|
||||
'kind': 'resource',
|
||||
'resource_id': res.id,
|
||||
'name': res.name or f'Resource #{res.id}',
|
||||
'file_name': res.name or '',
|
||||
'mime_type': '',
|
||||
})
|
||||
attached.append(rec.to_api_dict())
|
||||
|
||||
return _json_response({
|
||||
'attached': attached,
|
||||
'skipped_existing': skipped,
|
||||
'missing': missing,
|
||||
'count': len(attached),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.attach_library_resources failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reindex_source(self, plan_id, source_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||
return _error_response('Source not found', 404)
|
||||
rec.action_index()
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.reindex_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_source(self, plan_id, source_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||
return _error_response('Source not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE B — Deliverables preview / progress
|
||||
@@ -291,13 +432,13 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def get_deliverables(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response(compute_deliverables(plan))
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.deliverables failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE C — Multimedia generation per material
|
||||
@@ -307,8 +448,48 @@ class CoursePlanController(http.Controller):
|
||||
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
|
||||
if not rec.exists():
|
||||
return None
|
||||
self._assert_material_access(rec)
|
||||
return rec
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>',
|
||||
type='http', auth='none', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def update_material(self, material_id, **kw):
|
||||
"""Edit generated material metadata/content without regenerating."""
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
vals = {}
|
||||
if 'title' in body:
|
||||
new_title = (body.get('title') or '').strip()
|
||||
if not new_title:
|
||||
return _error_response('title cannot be empty', 400)
|
||||
vals['title'] = new_title
|
||||
if 'summary' in body:
|
||||
vals['summary'] = (body.get('summary') or '').strip()
|
||||
if 'is_static' in body:
|
||||
vals['is_static'] = bool(body.get('is_static'))
|
||||
if 'share_date' in body:
|
||||
vals['share_date'] = body.get('share_date') or False
|
||||
if 'body' in body:
|
||||
vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False)
|
||||
if 'body_text' in body:
|
||||
body_text = (body.get('body_text') or '').strip()
|
||||
vals['body_text'] = body_text
|
||||
if 'body' not in body and body_text:
|
||||
# Keep non-technical editing simple: if only plain text is
|
||||
# provided, mirror it into a minimal JSON structure.
|
||||
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
|
||||
if vals:
|
||||
material.sudo().write(vals)
|
||||
return _json_response({'data': material.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.update_material failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -324,12 +505,13 @@ class CoursePlanController(http.Controller):
|
||||
voice=body.get('voice'),
|
||||
language=body.get('language') or 'en-GB',
|
||||
gender=body.get('gender') or 'female',
|
||||
provider=body.get('provider') or 'polly',
|
||||
provider=body.get('provider') or 'auto',
|
||||
)
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_audio failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -351,7 +533,8 @@ class CoursePlanController(http.Controller):
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_image failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -366,7 +549,8 @@ class CoursePlanController(http.Controller):
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_video failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@@ -382,7 +566,63 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_material_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/media/<id>/raw
|
||||
# ------------------------------------------------------------------
|
||||
# Streams the binary backing a media row. Used as the ``src`` for
|
||||
# ``<img>`` / ``<audio>`` / ``<video>`` tags AND as the target of
|
||||
# download links — the only difference is the ``?download=1`` flag.
|
||||
#
|
||||
# Accepts the JWT via the standard ``Authorization: Bearer …`` header
|
||||
# OR via ``?token=<jwt>`` / ``?access_token=<jwt>`` so plain ``<img>``
|
||||
# tags can render the asset without us blob-converting every fetch.
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>/raw',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
def stream_media(self, media_id, **kw):
|
||||
try:
|
||||
user = validate_token(allow_query_param=True)
|
||||
if not user:
|
||||
return _error_response('Authentication required', 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists() or not rec.attachment_id:
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
|
||||
attachment = rec.attachment_id
|
||||
payload = attachment.raw or b''
|
||||
if not payload and attachment.datas:
|
||||
# Older rows store the binary base64-encoded under ``datas``.
|
||||
payload = base64.b64decode(attachment.datas)
|
||||
if not payload:
|
||||
return _error_response('Media payload missing', 410)
|
||||
|
||||
mimetype = attachment.mimetype or rec.mime_type or 'application/octet-stream'
|
||||
filename = attachment.name or f'media-{rec.id}'
|
||||
disposition = (
|
||||
f'attachment; filename="{filename}"'
|
||||
if request.httprequest.args.get('download')
|
||||
else f'inline; filename="{filename}"'
|
||||
)
|
||||
headers = [
|
||||
('Content-Type', mimetype),
|
||||
('Content-Length', str(len(payload))),
|
||||
('Content-Disposition', disposition),
|
||||
# Aggressive caching is safe — the URL is keyed by the row id
|
||||
# and the binary is immutable once generated. Setting a 1h
|
||||
# max-age avoids re-streaming the same WAV/PNG every time the
|
||||
# admin re-opens the media drawer.
|
||||
('Cache-Control', 'private, max-age=3600'),
|
||||
]
|
||||
return request.make_response(payload, headers=headers)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.stream_media failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@@ -392,13 +632,15 @@ class CoursePlanController(http.Controller):
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists():
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
if rec.attachment_id:
|
||||
rec.attachment_id.unlink()
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -411,9 +653,7 @@ class CoursePlanController(http.Controller):
|
||||
depends on ffmpeg + the audio + image steps and is slower.
|
||||
"""
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
week = plan.week_ids.filtered(
|
||||
lambda w: w.week_number == int(week_number),
|
||||
)
|
||||
@@ -443,7 +683,8 @@ class CoursePlanController(http.Controller):
|
||||
return _json_response({'items': results, 'count': len(results)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_week_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE D — Plan assignments
|
||||
@@ -454,25 +695,23 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_assignments(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [a.to_api_dict() for a in plan.assignment_ids],
|
||||
'count': len(plan.assignment_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_assignments failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_assignment(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
body = _get_json_body() or {}
|
||||
mode = (body.get('mode') or 'batch').strip()
|
||||
vals = {
|
||||
@@ -485,25 +724,40 @@ class CoursePlanController(http.Controller):
|
||||
if mode == 'batch':
|
||||
if not body.get('batch_id'):
|
||||
return _error_response('batch_id is required', 400)
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
batch_id = int(body['batch_id'])
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
if plan.entity_id and batch.entity_id and plan.entity_id.id != batch.entity_id.id:
|
||||
return _error_response('Batch entity does not match plan entity', 400)
|
||||
vals['batch_id'] = batch_id
|
||||
elif mode == 'students':
|
||||
ids = body.get('student_user_ids') or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return _error_response('student_user_ids is required', 400)
|
||||
vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])]
|
||||
elif mode == 'entities':
|
||||
ids = body.get('entity_ids') or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return _error_response('entity_ids is required', 400)
|
||||
checked = [_ensure_entity_access(int(i)) for i in ids]
|
||||
vals['entity_ids'] = [(6, 0, checked)]
|
||||
else:
|
||||
return _error_response('Invalid mode', 400)
|
||||
rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_assignment(self, plan_id, assignment_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
|
||||
int(assignment_id),
|
||||
)
|
||||
@@ -511,9 +765,11 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response('Assignment not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE E — Student-side endpoints
|
||||
@@ -535,12 +791,7 @@ class CoursePlanController(http.Controller):
|
||||
try:
|
||||
user = request.env.user
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
assignments = Assignment.search([
|
||||
('state', '=', 'active'),
|
||||
'|',
|
||||
('student_user_ids', 'in', [user.id]),
|
||||
'&', ('mode', '=', 'batch'), ('batch_id', '!=', False),
|
||||
])
|
||||
assignments = Assignment.search([('state', '=', 'active')])
|
||||
visible = []
|
||||
for a in assignments:
|
||||
if a.mode == 'students' and user.id in a.student_user_ids.ids:
|
||||
@@ -548,6 +799,9 @@ class CoursePlanController(http.Controller):
|
||||
continue
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
continue
|
||||
if a.mode == 'entities' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
@@ -582,6 +836,9 @@ class CoursePlanController(http.Controller):
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
allowed = True
|
||||
break
|
||||
if a.mode == 'entities' and user.id in a.expand_user_ids():
|
||||
allowed = True
|
||||
break
|
||||
if not allowed:
|
||||
return _error_response('Plan not assigned to you', 403)
|
||||
return _json_response({
|
||||
|
||||
@@ -54,6 +54,13 @@ class CoursePlan(models.Model):
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
@@ -142,6 +149,15 @@ class CoursePlan(models.Model):
|
||||
rec.media_count = len(rec.media_ids)
|
||||
rec.assignment_count = len(rec.assignment_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
user = self.env.user.sudo()
|
||||
default_entity = user.entity_ids[:1].id if hasattr(user, 'entity_ids') else False
|
||||
for vals in vals_list:
|
||||
if vals.get('entity_id') is None and default_entity:
|
||||
vals['entity_id'] = default_entity
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation helpers — used by the REST controller so payload
|
||||
# shape stays in a single, obvious place.
|
||||
@@ -161,6 +177,8 @@ class CoursePlan(models.Model):
|
||||
data = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'entity_id': self.entity_id.id if self.entity_id else None,
|
||||
'entity_name': self.entity_id.name if self.entity_id else '',
|
||||
'course_id': self.course_id.id if self.course_id else None,
|
||||
'course_name': self.course_id.name if self.course_id else '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
@@ -264,6 +282,13 @@ class CoursePlanMaterial(models.Model):
|
||||
MATERIAL_TYPE_SELECTION, required=True, default='other',
|
||||
)
|
||||
title = fields.Char(required=True)
|
||||
is_static = fields.Boolean(
|
||||
default=False,
|
||||
help='When enabled, regenerate-week keeps this material untouched.',
|
||||
)
|
||||
share_date = fields.Date(
|
||||
help='Optional date when the material becomes visible/shared.',
|
||||
)
|
||||
summary = fields.Text(
|
||||
help='Short blurb — purpose / learning outcomes targeted / how to use.',
|
||||
)
|
||||
@@ -300,6 +325,8 @@ class CoursePlanMaterial(models.Model):
|
||||
'skill': self.skill or '',
|
||||
'material_type': self.material_type or 'other',
|
||||
'title': self.title or '',
|
||||
'is_static': bool(self.is_static),
|
||||
'share_date': self.share_date.isoformat() if self.share_date else None,
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
|
||||
@@ -22,6 +22,7 @@ _logger = logging.getLogger(__name__)
|
||||
ASSIGNMENT_MODE_SELECTION = [
|
||||
('batch', 'Class / Batch'),
|
||||
('students', 'Specific students'),
|
||||
('entities', 'Entities'),
|
||||
]
|
||||
|
||||
ASSIGNMENT_STATE_SELECTION = [
|
||||
@@ -47,6 +48,10 @@ class CoursePlanAssignment(models.Model):
|
||||
'res.users', 'course_plan_assignment_student_rel',
|
||||
'assignment_id', 'user_id', string='Specific students',
|
||||
)
|
||||
entity_ids = fields.Many2many(
|
||||
'encoach.entity', 'course_plan_assignment_entity_rel',
|
||||
'assignment_id', 'entity_id', string='Entities',
|
||||
)
|
||||
|
||||
assigned_by_id = fields.Many2one(
|
||||
'res.users', default=lambda self: self.env.user, string='Assigned by',
|
||||
@@ -57,7 +62,7 @@ class CoursePlanAssignment(models.Model):
|
||||
|
||||
student_count = fields.Integer(compute='_compute_student_count', store=False)
|
||||
|
||||
@api.depends('mode', 'batch_id', 'student_user_ids')
|
||||
@api.depends('mode', 'batch_id', 'student_user_ids', 'entity_ids')
|
||||
def _compute_student_count(self):
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
Batch = self.env['op.batch'].sudo()
|
||||
@@ -65,6 +70,14 @@ class CoursePlanAssignment(models.Model):
|
||||
if rec.mode == 'students':
|
||||
rec.student_count = len(rec.student_user_ids)
|
||||
continue
|
||||
if rec.mode == 'entities':
|
||||
user_ids = set()
|
||||
for entity in rec.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.add(user.id)
|
||||
rec.student_count = len(user_ids)
|
||||
continue
|
||||
if not rec.batch_id:
|
||||
rec.student_count = 0
|
||||
continue
|
||||
@@ -85,6 +98,13 @@ class CoursePlanAssignment(models.Model):
|
||||
self.ensure_one()
|
||||
if self.mode == 'students':
|
||||
return self.student_user_ids.ids
|
||||
if self.mode == 'entities':
|
||||
user_ids = []
|
||||
for entity in self.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.append(user.id)
|
||||
return list(set(user_ids))
|
||||
if self.mode == 'batch' and self.batch_id:
|
||||
try:
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
@@ -118,6 +138,8 @@ class CoursePlanAssignment(models.Model):
|
||||
'batch_name': self.batch_id.name if self.batch_id else '',
|
||||
'student_user_ids': self.student_user_ids.ids,
|
||||
'student_user_names': [u.name for u in self.student_user_ids],
|
||||
'entity_ids': self.entity_ids.ids,
|
||||
'entity_names': [e.name for e in self.entity_ids],
|
||||
'student_count': self.student_count or 0,
|
||||
'assigned_by_id': self.assigned_by_id.id if self.assigned_by_id else None,
|
||||
'assigned_by_name': self.assigned_by_id.name if self.assigned_by_id else '',
|
||||
|
||||
@@ -26,12 +26,25 @@ MEDIA_KIND_SELECTION = [
|
||||
]
|
||||
|
||||
MEDIA_PROVIDER_SELECTION = [
|
||||
# ── Paid providers ──
|
||||
('polly', 'AWS Polly'),
|
||||
('elevenlabs', 'ElevenLabs'),
|
||||
('openai_image', 'OpenAI (DALL-E)'),
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('elai', 'Elai.io'),
|
||||
# ── Free fallbacks (Phase 24.1) ──
|
||||
('pillow', 'Pillow placeholder (offline)'),
|
||||
('unsplash', 'Unsplash Source (free)'),
|
||||
('gtts', 'gTTS (free TTS)'),
|
||||
('silent', 'Silent stub'),
|
||||
('static', 'Static image as video'),
|
||||
('mock', 'Mock'),
|
||||
# ── Composers / manual ──
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('manual', 'Manual upload'),
|
||||
# Sentinel value used while the chain is still resolving — written
|
||||
# transiently by MediaService.create() and overwritten with the
|
||||
# successful provider name once a fallback step succeeds.
|
||||
('auto', 'Auto (fallback chain)'),
|
||||
]
|
||||
|
||||
MEDIA_STATUS_SELECTION = [
|
||||
@@ -76,8 +89,16 @@ class CoursePlanMedia(models.Model):
|
||||
width = fields.Integer()
|
||||
height = fields.Integer()
|
||||
download_url = fields.Char(
|
||||
compute='_compute_download_url', store=False,
|
||||
help='Web-accessible URL served by Odoo (/web/content/<id>).',
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary as an attachment '
|
||||
'(``Content-Disposition: attachment``). Frontend appends '
|
||||
'``?token=<jwt>`` for download buttons.',
|
||||
)
|
||||
preview_url = fields.Char(
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary inline so it can '
|
||||
'be used as the ``src`` for ``<img>`` / ``<audio>`` / ``<video>`` '
|
||||
'elements. Frontend appends ``?token=<jwt>``.',
|
||||
)
|
||||
|
||||
status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued')
|
||||
@@ -89,13 +110,24 @@ class CoursePlanMedia(models.Model):
|
||||
)
|
||||
|
||||
@api.depends('attachment_id')
|
||||
def _compute_download_url(self):
|
||||
def _compute_media_urls(self):
|
||||
# Both URLs hit the same JWT-protected streaming endpoint exposed by
|
||||
# ``encoach_ai_course.controllers.course_plan.CoursePlanController.
|
||||
# stream_media``. The endpoint accepts the JWT either as a Bearer
|
||||
# header (REST clients) or as a ``?token=`` query param so plain
|
||||
# ``<img>`` / ``<audio>`` / ``<video>`` tags can render the asset
|
||||
# without us proxying every request through fetch + blob URLs.
|
||||
# ``download=1`` only changes the Content-Disposition: attachment
|
||||
# header so the same route serves both inline previews and explicit
|
||||
# downloads.
|
||||
for rec in self:
|
||||
rec.download_url = (
|
||||
f'/web/content/{rec.attachment_id.id}?download=true&filename='
|
||||
f'{rec.attachment_id.name or "media"}'
|
||||
if rec.attachment_id else ''
|
||||
)
|
||||
if not rec.id:
|
||||
rec.download_url = ''
|
||||
rec.preview_url = ''
|
||||
continue
|
||||
base = f'/api/ai/course-plan/media/{rec.id}/raw'
|
||||
rec.preview_url = base
|
||||
rec.download_url = f'{base}?download=1'
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
@@ -117,6 +149,7 @@ class CoursePlanMedia(models.Model):
|
||||
'height': self.height or 0,
|
||||
'attachment_id': self.attachment_id.id if self.attachment_id else None,
|
||||
'download_url': self.download_url,
|
||||
'preview_url': self.preview_url,
|
||||
'status': self.status or 'queued',
|
||||
'error': self.error or '',
|
||||
'cost_cents': self.cost_cents or 0,
|
||||
|
||||
@@ -25,6 +25,13 @@ SOURCE_KIND_SELECTION = [
|
||||
('file', 'File'),
|
||||
('url', 'URL'),
|
||||
('text', 'Inline text'),
|
||||
# ``resource`` is a soft-link to the central ``encoach.resource``
|
||||
# library so the admin can re-use a PDF / DOCX / link they already
|
||||
# uploaded under /admin/resources without re-uploading the binary.
|
||||
# The indexer dereferences the link at extraction time; the binary
|
||||
# itself stays in the library record so a single edit / rotation
|
||||
# propagates to every plan that grounds on it.
|
||||
('resource', 'Library resource'),
|
||||
]
|
||||
|
||||
SOURCE_STATUS_SELECTION = [
|
||||
@@ -56,6 +63,20 @@ class CoursePlanSource(models.Model):
|
||||
url = fields.Char(string='Source URL')
|
||||
inline_text = fields.Text(string='Inline text')
|
||||
|
||||
# Optional pointer to the central /admin/resources library. When set
|
||||
# the indexer pulls the binary / URL / inline text from the linked
|
||||
# ``encoach.resource`` instead of re-storing it on this row, which
|
||||
# avoids duplicating large PDFs across every plan that grounds on
|
||||
# the same library item.
|
||||
resource_id = fields.Many2one(
|
||||
'encoach.resource',
|
||||
string='Library resource',
|
||||
ondelete='set null',
|
||||
help='Link to a resource already uploaded under /admin/resources. '
|
||||
'The indexer reads the binary from the library at extraction '
|
||||
'time so updates to the library propagate to every plan.',
|
||||
)
|
||||
|
||||
auto_index = fields.Boolean(
|
||||
default=True,
|
||||
help='If true, indexing runs automatically on create. '
|
||||
@@ -74,18 +95,44 @@ class CoursePlanSource(models.Model):
|
||||
if rec.auto_index:
|
||||
try:
|
||||
rec.action_index()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# If indexing crashes before SourceIndexer has a chance
|
||||
# to mark the row as ``failed`` (e.g. an unexpected
|
||||
# import or DB error) we MUST still set the status to
|
||||
# ``failed``; otherwise the source sits in ``pending``
|
||||
# forever and the deliverables UI can't show progress.
|
||||
_logger.exception('Auto-index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
|
||||
def action_index(self):
|
||||
"""(Re-)extract text and push chunks to the vector store."""
|
||||
"""(Re-)extract text and push chunks to the vector store.
|
||||
|
||||
Wraps each per-record indexing call so a failure on one source
|
||||
doesn't abort the loop and leave later records orphaned.
|
||||
"""
|
||||
from odoo.addons.encoach_ai_course.services.source_indexer import (
|
||||
SourceIndexer,
|
||||
)
|
||||
indexer = SourceIndexer(self.env)
|
||||
for rec in self:
|
||||
indexer = SourceIndexer(self.env)
|
||||
indexer.index(rec)
|
||||
try:
|
||||
indexer.index(rec)
|
||||
except Exception as exc:
|
||||
_logger.exception('Index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def unlink(self):
|
||||
@@ -111,6 +158,11 @@ class CoursePlanSource(models.Model):
|
||||
'mime_type': self.mime_type or '',
|
||||
'url': self.url or '',
|
||||
'has_inline_text': bool(self.inline_text),
|
||||
'resource_id': self.resource_id.id if self.resource_id else None,
|
||||
'resource_name': self.resource_id.name if self.resource_id else '',
|
||||
'resource_type': (
|
||||
self.resource_id.type if self.resource_id else ''
|
||||
),
|
||||
'status': self.status or 'pending',
|
||||
'error': self.error or '',
|
||||
'chunks_count': self.chunks_count or 0,
|
||||
|
||||
@@ -29,6 +29,21 @@ try:
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
# Markers that indicate the OpenAI account is unusable until the operator
|
||||
# fixes billing / keys / quota — i.e. retrying right now will never help.
|
||||
# We mirror the OpenAI service's own non-retryable list so a 429 caused by
|
||||
# `insufficient_quota` triggers the free fallback instead of bubbling up
|
||||
# as a 500 from the wizard's "Finish" button.
|
||||
_AI_PERMANENT_FAILURE_MARKERS = (
|
||||
"insufficient_quota",
|
||||
"invalid_api_key",
|
||||
"incorrect_api_key",
|
||||
"account_deactivated",
|
||||
"billing_hard_limit_reached",
|
||||
"openai not configured",
|
||||
"ai is disabled",
|
||||
)
|
||||
|
||||
# AgentRuntime is the LangGraph-backed engine. When the feature flag
|
||||
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
|
||||
# the matching key is configured, the pipeline routes through the agent
|
||||
@@ -43,6 +58,20 @@ except ImportError: # pragma: no cover - optional dep
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_permanent_ai_failure(message):
|
||||
"""Return True iff the AI error indicates a non-transient condition.
|
||||
|
||||
These are operator-fixable problems (out of credit, wrong key, AI
|
||||
feature switched off) where retrying would just hang the wizard. In
|
||||
those cases the pipeline degrades to a deterministic skeleton plan
|
||||
so the user still gets a usable record.
|
||||
"""
|
||||
if not message:
|
||||
return False
|
||||
low = str(message).lower()
|
||||
return any(m in low for m in _AI_PERMANENT_FAILURE_MARKERS)
|
||||
|
||||
|
||||
# JSON schema we coax the LLM into following. Keeping this as a prompt
|
||||
# string (rather than an OpenAI function call) makes it portable if the
|
||||
# underlying `chat_json` implementation ever changes providers.
|
||||
@@ -270,10 +299,46 @@ class CoursePlanPipeline:
|
||||
max_tokens=4096,
|
||||
action="course_plan.generate",
|
||||
)
|
||||
|
||||
# If the LLM is unavailable (quota exhausted, missing key,
|
||||
# disabled, network error) we degrade to a deterministic stub
|
||||
# plan instead of raising. The wizard's "Finish" button needs to
|
||||
# always succeed in producing a record the user can open and
|
||||
# iterate on; a hard failure here leaves the frontend stuck on a
|
||||
# 5+ minute spinner while the OpenAI client retries.
|
||||
used_fallback = False
|
||||
ai_error = None
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
ai_error = (content or {}).get('error', 'AI generation failed.')
|
||||
if _is_permanent_ai_failure(ai_error):
|
||||
_logger.warning(
|
||||
"Course plan AI unavailable (%s); using free fallback",
|
||||
ai_error,
|
||||
)
|
||||
content = self._build_fallback_plan_content(
|
||||
title=title,
|
||||
cefr=cefr,
|
||||
total_weeks=total_weeks,
|
||||
contact_hours=contact_hours,
|
||||
skills_division=skills_division,
|
||||
grammar_focus=grammar_focus,
|
||||
resources=resources,
|
||||
learner_profile=learner_profile,
|
||||
)
|
||||
used_fallback = True
|
||||
else:
|
||||
# Genuine transient failure — surface it to the caller so
|
||||
# they can retry. The frontend toasts the error message.
|
||||
raise RuntimeError(ai_error)
|
||||
|
||||
description = (content.get('description') or '').strip()
|
||||
if used_fallback:
|
||||
description = (
|
||||
description
|
||||
+ "\n\n[Auto-generated skeleton — OpenAI unavailable. "
|
||||
"Update the AI provider settings or restore billing, "
|
||||
"then click Regenerate.]"
|
||||
).strip()
|
||||
|
||||
plan_vals = {
|
||||
'name': title,
|
||||
@@ -283,14 +348,14 @@ class CoursePlanPipeline:
|
||||
'total_weeks': total_weeks,
|
||||
'contact_hours_per_week': contact_hours,
|
||||
'skills_division': skills_division,
|
||||
'description': (content.get('description') or '').strip(),
|
||||
'description': description,
|
||||
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
|
||||
'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
|
||||
'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
|
||||
'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
|
||||
'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
|
||||
'brief_json': json.dumps(brief, ensure_ascii=False),
|
||||
'status': 'generated',
|
||||
'status': 'generated' if not used_fallback else 'draft',
|
||||
}
|
||||
if brief.get('course_id'):
|
||||
try:
|
||||
@@ -370,10 +435,23 @@ class CoursePlanPipeline:
|
||||
max_tokens=6000,
|
||||
action="course_plan.generate_week",
|
||||
)
|
||||
used_week_fallback = False
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
ai_error = (content or {}).get('error', 'AI generation failed.')
|
||||
if _is_permanent_ai_failure(ai_error):
|
||||
_logger.warning(
|
||||
"Week materials AI unavailable (%s); using free fallback",
|
||||
ai_error,
|
||||
)
|
||||
content = self._build_fallback_week_content(
|
||||
plan_name=plan.name,
|
||||
cefr=(plan.cefr_level or "").lower(),
|
||||
week_number=week.week_number,
|
||||
items=items,
|
||||
)
|
||||
used_week_fallback = True
|
||||
else:
|
||||
raise RuntimeError(ai_error)
|
||||
|
||||
# Wipe any previous materials for this week so re-generating is
|
||||
# idempotent and we never accumulate duplicates.
|
||||
@@ -381,19 +459,28 @@ class CoursePlanPipeline:
|
||||
('plan_id', '=', plan.id), ('week_id', '=', week.id),
|
||||
])
|
||||
if existing:
|
||||
existing.unlink()
|
||||
# Keep instructor-curated static rows; only replace generated ones.
|
||||
existing.filtered(lambda m: not m.is_static).unlink()
|
||||
|
||||
Material = self.env['encoach.course.plan.material'].sudo()
|
||||
created = []
|
||||
skeleton_note = (
|
||||
"[Auto-generated skeleton — OpenAI unavailable. "
|
||||
"Update the AI provider settings or restore billing, "
|
||||
"then regenerate this week's materials.]"
|
||||
)
|
||||
for m in content.get('materials') or []:
|
||||
try:
|
||||
summary = (m.get('summary') or '').strip()
|
||||
if used_week_fallback:
|
||||
summary = (summary + "\n\n" + skeleton_note).strip()
|
||||
rec = Material.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'skill': (m.get('skill') or 'integrated').strip().lower(),
|
||||
'material_type': (m.get('material_type') or 'other').strip(),
|
||||
'title': (m.get('title') or '').strip() or 'Untitled',
|
||||
'summary': (m.get('summary') or '').strip(),
|
||||
'summary': summary,
|
||||
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
|
||||
'body_text': self._flatten_body(m.get('body') or {}),
|
||||
})
|
||||
@@ -440,10 +527,21 @@ class CoursePlanPipeline:
|
||||
payload=user_msg,
|
||||
extra_system=system_msg,
|
||||
)
|
||||
if final.get("error"):
|
||||
agent_error = final.get("error")
|
||||
if agent_error:
|
||||
# Permanent failures (no quota, bad key, AI off) will fail
|
||||
# the same way through the legacy chat_json path, so don't
|
||||
# double the wait — surface the error and let the caller
|
||||
# decide (generate_plan triggers the free fallback).
|
||||
if _is_permanent_ai_failure(agent_error):
|
||||
_logger.warning(
|
||||
"agent %s permanent failure (%s); skipping legacy fallback",
|
||||
agent_key, agent_error,
|
||||
)
|
||||
return {"error": agent_error}
|
||||
_logger.warning(
|
||||
"agent %s failed (%s); falling back to direct chat_json",
|
||||
agent_key, final.get("error"),
|
||||
agent_key, agent_error,
|
||||
)
|
||||
else:
|
||||
output = final.get("output")
|
||||
@@ -465,6 +563,186 @@ class CoursePlanPipeline:
|
||||
action=action,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Free fallback — used when OpenAI returns a permanent failure (no
|
||||
# quota, bad key, AI disabled). We synthesize a structurally-valid
|
||||
# plan so the wizard still completes and the user gets a record they
|
||||
# can edit or regenerate. The shape mirrors the JSON schema in
|
||||
# _PLAN_JSON_HINT exactly so downstream serializers don't notice.
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_fallback_plan_content(
|
||||
*, title, cefr, total_weeks, contact_hours, skills_division,
|
||||
grammar_focus, resources, learner_profile,
|
||||
):
|
||||
cefr_upper = (cefr or "a2").upper()
|
||||
skills_text = (skills_division or "").strip() or (
|
||||
"Reading & Writing balanced with Listening & Speaking"
|
||||
)
|
||||
outcomes = {
|
||||
"reading": [{"code": "RLO1", "description": f"Read level-appropriate ({cefr_upper}) texts and identify main ideas."}],
|
||||
"writing": [{"code": "WLO1", "description": "Plan and write short structured paragraphs on familiar topics."}],
|
||||
"listening": [{"code": "LLO1", "description": "Follow short dialogues and monologues at normal speed."}],
|
||||
"speaking": [{"code": "SLO1", "description": "Hold short conversations on personal and study-related topics."}],
|
||||
"vocabulary": [{"code": "VLO1", "description": "Use a level-appropriate active vocabulary across the four skills."}],
|
||||
"grammar": [{"code": "GLO1", "description": "Use the targeted grammar structures accurately in context."}],
|
||||
}
|
||||
grammar_blocks = [
|
||||
{"code": f"GT{i+1}", "label": label.strip() or f"Topic {i+1}",
|
||||
"sub_items": []}
|
||||
for i, label in enumerate((grammar_focus or [])[:6])
|
||||
] or [
|
||||
{"code": "GT1", "label": "Present tenses", "sub_items": ["present simple", "present continuous"]},
|
||||
{"code": "GT2", "label": "Past tenses", "sub_items": ["past simple", "past continuous"]},
|
||||
]
|
||||
weeks = []
|
||||
for w in range(1, max(1, int(total_weeks or 12)) + 1):
|
||||
weeks.append({
|
||||
"week_number": w,
|
||||
"date_label": f"Week {w}",
|
||||
"unit": f"Unit {((w - 1) // 2) + 1}",
|
||||
"focus": f"Skeleton focus for week {w} — replace via Regenerate.",
|
||||
"items": [
|
||||
{"skill": "reading", "outcome_codes": ["RLO1"], "remarks": ""},
|
||||
{"skill": "writing", "outcome_codes": ["WLO1"], "remarks": ""},
|
||||
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
|
||||
{"skill": "speaking", "outcome_codes": ["SLO1"], "remarks": ""},
|
||||
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""},
|
||||
],
|
||||
})
|
||||
return {
|
||||
"description": (
|
||||
f"{cefr_upper} general course over {total_weeks} weeks, "
|
||||
f"approximately {contact_hours} contact hours per week. "
|
||||
f"Coverage: {skills_text}. "
|
||||
f"Profile: {learner_profile or 'mixed adult learners'}."
|
||||
),
|
||||
"objectives": [
|
||||
f"Develop integrated {cefr_upper}-level skills across reading, writing, listening and speaking.",
|
||||
"Build active vocabulary and accurate use of target grammar.",
|
||||
"Use language confidently in personal, social and study contexts.",
|
||||
],
|
||||
"outcomes": outcomes,
|
||||
"grammar": grammar_blocks,
|
||||
"assessment": {
|
||||
"continuous_assessment": {
|
||||
"total_weight": 50,
|
||||
"components": [
|
||||
{"name": "MTE", "weight": 30},
|
||||
{"name": "Continuous tasks", "weight": 20},
|
||||
],
|
||||
},
|
||||
"final_exam": {"total_weight": 50},
|
||||
},
|
||||
"resources": [
|
||||
{"type": "textbook", "citation": (resources[0] if resources else "TBD — replace via Regenerate")},
|
||||
],
|
||||
"weeks": weeks,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Free fallback for per-week materials. Mirrors the schema in
|
||||
# _WEEK_JSON_HINT — placeholder content per skill so the teacher
|
||||
# has a starter row they can edit or regenerate later.
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_fallback_week_content(*, plan_name, cefr, week_number, items):
|
||||
cefr_upper = (cefr or "a2").upper()
|
||||
# Only produce materials for the skills that the week's plan
|
||||
# actually contains, so the teacher's outline is preserved.
|
||||
wanted_skills = []
|
||||
seen = set()
|
||||
for it in items or []:
|
||||
s = (it.get("skill") or "").strip().lower()
|
||||
if s and s not in seen:
|
||||
wanted_skills.append(s)
|
||||
seen.add(s)
|
||||
if not wanted_skills:
|
||||
wanted_skills = ["reading", "writing", "listening", "speaking", "grammar", "vocabulary"]
|
||||
|
||||
templates = {
|
||||
"reading": {
|
||||
"material_type": "reading_text",
|
||||
"title": f"Week {week_number} reading — placeholder",
|
||||
"summary": f"Skeleton reading task at {cefr_upper}. Replace via Regenerate.",
|
||||
"body": {
|
||||
"text": (
|
||||
"Placeholder reading passage. Replace with a "
|
||||
f"{cefr_upper}-level text of around 400 words on "
|
||||
"a familiar personal or study-related topic."
|
||||
),
|
||||
"questions": [
|
||||
{"q": "What is the main idea?", "type": "short_answer", "answer": "TBD"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"writing": {
|
||||
"material_type": "writing_prompt",
|
||||
"title": f"Week {week_number} writing — placeholder",
|
||||
"summary": "Skeleton writing task. Replace via Regenerate.",
|
||||
"body": {
|
||||
"prompt": "Write a short paragraph about your weekly routine.",
|
||||
"word_count": 150,
|
||||
"model_paragraph": "(Add a model paragraph after regenerating.)",
|
||||
},
|
||||
},
|
||||
"listening": {
|
||||
"material_type": "listening_script",
|
||||
"title": f"Week {week_number} listening — placeholder",
|
||||
"summary": "Skeleton listening script. Replace via Regenerate.",
|
||||
"body": {
|
||||
"script": (
|
||||
"(Placeholder script.) Two friends discuss their "
|
||||
"morning routines and weekend plans."
|
||||
),
|
||||
"comprehension_questions": [
|
||||
{"q": "Who is speaking?", "answer": "Two friends."},
|
||||
],
|
||||
},
|
||||
},
|
||||
"speaking": {
|
||||
"material_type": "speaking_prompt",
|
||||
"title": f"Week {week_number} speaking — placeholder",
|
||||
"summary": "Skeleton speaking prompts. Replace via Regenerate.",
|
||||
"body": {
|
||||
"prompts": [
|
||||
"Describe a typical day in your life.",
|
||||
"Talk about something you do at the weekend.",
|
||||
],
|
||||
"useful_language": ["usually", "often", "sometimes", "never"],
|
||||
},
|
||||
},
|
||||
"grammar": {
|
||||
"material_type": "grammar_lesson",
|
||||
"title": f"Week {week_number} grammar — placeholder",
|
||||
"summary": "Skeleton grammar mini-lesson. Replace via Regenerate.",
|
||||
"body": {
|
||||
"explanation": "Target structure for the week — replace via Regenerate.",
|
||||
"examples": ["I work every day.", "She doesn't drink coffee."],
|
||||
"practice": [
|
||||
{"q": "He ___ (work) in a bank.", "answer": "works"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"vocabulary": {
|
||||
"material_type": "vocabulary_list",
|
||||
"title": f"Week {week_number} vocabulary — placeholder",
|
||||
"summary": "Skeleton vocabulary set. Replace via Regenerate.",
|
||||
"body": {
|
||||
"words": [
|
||||
{"term": "routine", "pos": "n.", "definition": "a regular sequence of activities", "example": "My morning routine is busy."},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
materials = []
|
||||
for skill in wanted_skills:
|
||||
t = templates.get(skill)
|
||||
if t is None:
|
||||
continue
|
||||
materials.append({"skill": skill, **t})
|
||||
return {"materials": materials}
|
||||
|
||||
@staticmethod
|
||||
def _flatten_body(body):
|
||||
"""Produce a plain-text dump of a material body for quick preview.
|
||||
|
||||
@@ -4,35 +4,30 @@ Three modalities — each persists an ``encoach.course.plan.media`` row
|
||||
with the bytes attached as an ``ir.attachment`` so the existing
|
||||
``/web/content/<id>`` URL serving works without extra plumbing.
|
||||
|
||||
Audio:
|
||||
Synthesise a TTS narration of a listening script or speaking
|
||||
model-answer using AWS Polly (preferred) with a fallback to
|
||||
ElevenLabs when configured. The voice picks itself from the plan's
|
||||
target CEFR + a ``voice_key`` param.
|
||||
PROVIDER FALLBACK CHAIN (Phase 24.1, Apr 2026)
|
||||
==============================================
|
||||
Every modality now tries providers in order: the explicitly-requested or
|
||||
admin-configured paid provider first, then a sequence of *free* fallbacks.
|
||||
A request that hits a billing/quota error (HTTP 402/429, OpenAI
|
||||
``insufficient_quota``, AWS Polly ``ThrottlingException``, ElevenLabs
|
||||
character-limit, etc.) is silently retried against the next provider in
|
||||
the chain — the request never fails just because the admin's API key has
|
||||
run out of credit.
|
||||
|
||||
Image:
|
||||
Use OpenAI's DALL-E 3 (via ``OpenAIService.generate_image``) with a
|
||||
structured prompt built from the material body. Per-plan image
|
||||
budgets are enforced so a single bad call doesn't bill an admin's
|
||||
OpenAI account dry.
|
||||
* Image: ``openai (DALL-E 3) → pillow (offline placeholder) → unsplash``.
|
||||
* Audio: ``polly | elevenlabs → gtts → silent-stub``.
|
||||
* Video: ``ffmpeg (image+audio) → static (text-only image card)``.
|
||||
|
||||
Video:
|
||||
Combine a generated image (or, if missing, generate one first)
|
||||
with the audio narration into an MP4 using a local ``ffmpeg``
|
||||
subprocess. No third-party rendering service required for the
|
||||
default install. ffmpeg presence is detected at call time and the
|
||||
media row is marked ``failed`` with a clear error if it's missing.
|
||||
|
||||
The service is deliberately stateless beyond the env handle so it can
|
||||
be invoked from controllers, agent tools, or batch crons.
|
||||
The active provider per capability is read fresh from
|
||||
``ir.config_parameter`` on every call (see
|
||||
:mod:`encoach_ai.services.provider_router`) so toggling a provider in
|
||||
the admin UI takes effect on the very next request without restarting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -42,6 +37,12 @@ from typing import Optional
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
from odoo.addons.encoach_ai.services import provider_router
|
||||
from odoo.addons.encoach_ai.services.provider_router import (
|
||||
classify_provider_error,
|
||||
should_fallback,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
|
||||
@@ -63,18 +64,16 @@ def _attach_bytes(env, *, name, mime_type, data: bytes,
|
||||
})
|
||||
|
||||
|
||||
def _deduce_voice(language: str, gender: str = 'female') -> tuple[str, str]:
|
||||
"""Return ``(provider, voice_id)`` tuple for the requested language."""
|
||||
lang = (language or 'en-GB').strip()
|
||||
return ('polly', '') # let the provider pick its default for the language
|
||||
|
||||
|
||||
def _get_param(env, key, default):
|
||||
return env['ir.config_parameter'].sudo().get_param(key, default)
|
||||
|
||||
|
||||
def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
|
||||
"""Raise if generating ``planned_images`` would exceed the per-plan cap."""
|
||||
"""Raise if generating ``planned_images`` would exceed the per-plan cap.
|
||||
|
||||
The budget only applies to *paid* image providers (DALL-E). Free
|
||||
fallbacks (Pillow, Unsplash) are unmetered.
|
||||
"""
|
||||
cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60'))
|
||||
if cap <= 0:
|
||||
return
|
||||
@@ -82,12 +81,14 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
|
||||
used = Media.search_count([
|
||||
('plan_id', '=', plan.id),
|
||||
('kind', '=', 'image'),
|
||||
('provider', '=', 'openai_image'),
|
||||
('status', 'in', ('ready', 'generating')),
|
||||
])
|
||||
if used + planned_images > cap:
|
||||
raise RuntimeError(
|
||||
f'Image budget exceeded for this plan: {used} used, cap is {cap}. '
|
||||
f'Raise encoach_ai_course.image_budget_per_plan or delete old images.'
|
||||
f'Paid image budget exceeded for this plan: {used} used, cap is '
|
||||
f'{cap}. Raise encoach_ai_course.image_budget_per_plan or delete '
|
||||
f'old DALL-E images. Free Pillow/Unsplash fallbacks remain available.'
|
||||
)
|
||||
|
||||
|
||||
@@ -130,7 +131,6 @@ def _build_image_prompt(material, *, plan) -> str:
|
||||
f'Scene: {snippet} Style: {style_hint}.'
|
||||
)
|
||||
if material.material_type == 'vocabulary_list':
|
||||
# Caller should pass a single term explicitly via ``custom_prompt``.
|
||||
words = body.get('words') or []
|
||||
if words:
|
||||
term = words[0].get('term') or material.title
|
||||
@@ -144,11 +144,31 @@ def _build_image_prompt(material, *, plan) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _image_subtitle(material, *, plan):
|
||||
"""Short subtitle string used by the offline placeholder card."""
|
||||
cefr = (plan.cefr_level or '').upper() or '—'
|
||||
parts = [f'CEFR {cefr}']
|
||||
if material.week_number:
|
||||
parts.append(f'Week {material.week_number}')
|
||||
if material.material_type:
|
||||
parts.append(material.material_type.replace('_', ' ').title())
|
||||
return ' · '.join(parts)
|
||||
|
||||
|
||||
# --- Public service ---------------------------------------------------------
|
||||
|
||||
|
||||
class MediaService:
|
||||
"""Generate audio / image / video assets for a course-plan material."""
|
||||
"""Generate audio / image / video assets for a course-plan material.
|
||||
|
||||
All three public methods (:meth:`synthesize_audio`,
|
||||
:meth:`generate_image`, :meth:`compose_video`) walk a provider chain
|
||||
and degrade gracefully — they never raise to the controller as long
|
||||
as at least one free fallback is wired up. The persisted
|
||||
``encoach.course.plan.media`` row records *which* provider actually
|
||||
succeeded, plus any errors hit along the way (concatenated into
|
||||
``error`` for diagnostics).
|
||||
"""
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
@@ -157,8 +177,19 @@ class MediaService:
|
||||
def synthesize_audio(self, material, *, voice: Optional[str] = None,
|
||||
language: str = 'en-GB',
|
||||
gender: str = 'female',
|
||||
provider: str = 'polly') -> 'models.Model':
|
||||
"""Generate a narration MP3 for ``material`` and persist it."""
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Generate a narration MP3 for ``material`` and persist it.
|
||||
|
||||
The ``provider`` argument behaves like the admin setting:
|
||||
|
||||
* ``'auto'`` — try paid then free fallbacks
|
||||
* a specific name (``polly``, ``elevenlabs``, ``gtts``,
|
||||
``silent``) — pin that provider; the chain still falls back
|
||||
to the free providers if it errors out
|
||||
|
||||
Even if every provider fails, the row is marked ``failed`` with
|
||||
a helpful error rather than raising.
|
||||
"""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
media = Media.create({
|
||||
'plan_id': material.plan_id.id,
|
||||
@@ -176,117 +207,252 @@ class MediaService:
|
||||
media.write({'status': 'failed', 'error': 'No script text to narrate'})
|
||||
return media
|
||||
media.write({'source_text': text[:3000]})
|
||||
try:
|
||||
audio_bytes = self._call_tts(
|
||||
text, voice=voice, language=language,
|
||||
gender=gender, provider=provider,
|
||||
)
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{material.plan_id.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.mp3',
|
||||
mime_type='audio/mpeg',
|
||||
data=audio_bytes,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': 'audio/mpeg',
|
||||
'size_bytes': len(audio_bytes),
|
||||
'status': 'ready',
|
||||
'error': False,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('TTS failed for material %s', material.id)
|
||||
media.write({'status': 'failed', 'error': str(exc)[:500]})
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'audio', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
result = self._call_audio_provider(
|
||||
prov, text=text, voice=voice,
|
||||
language=language, gender=gender,
|
||||
)
|
||||
content_type = result.get('content_type', 'audio/mpeg')
|
||||
ext = 'wav' if content_type == 'audio/wav' else 'mp3'
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{material.plan_id.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.{ext}',
|
||||
mime_type=content_type,
|
||||
data=result['audio'],
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': content_type,
|
||||
'size_bytes': len(result['audio']),
|
||||
'voice': result.get('voice') or voice or '',
|
||||
'provider': prov,
|
||||
'status': 'ready',
|
||||
'error': '\n'.join(errors)[:500] if errors else False,
|
||||
})
|
||||
return media
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'TTS provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
if not should_fallback(exc) and prov != chain[-1]:
|
||||
# ``other`` errors (not quota/auth/network) suggest a
|
||||
# genuine input problem, not provider exhaustion. Keep
|
||||
# falling back anyway because the user just wants audio
|
||||
# produced — but log loudly so we notice in production.
|
||||
_logger.exception(
|
||||
'Unclassified TTS error from %s — continuing chain', prov,
|
||||
)
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All audio providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
def _call_tts(self, text, *, voice, language, gender, provider):
|
||||
def _call_audio_provider(self, provider, *, text, voice, language, gender):
|
||||
if provider == 'polly':
|
||||
from odoo.addons.encoach_ai.services.polly_service import PollyService
|
||||
return PollyService(self.env).synthesize(
|
||||
text, voice=voice, language=language, gender=gender,
|
||||
)
|
||||
if provider == 'elevenlabs':
|
||||
from odoo.addons.encoach_ai.services.elevenlabs_service import (
|
||||
ElevenLabsService,
|
||||
)
|
||||
svc = ElevenLabsService(self.env)
|
||||
res = svc.synthesize(text, voice_id=voice or None)
|
||||
return res.get('audio') or res.get('audio_bytes') or b''
|
||||
from odoo.addons.encoach_ai.services.polly_service import (
|
||||
PollyService,
|
||||
)
|
||||
svc = PollyService(self.env)
|
||||
res = svc.synthesize(
|
||||
text, voice=voice, language=language, gender=gender,
|
||||
)
|
||||
return res['audio']
|
||||
res = ElevenLabsService(self.env).synthesize(
|
||||
text, voice_id=voice or None,
|
||||
)
|
||||
return {
|
||||
'audio': res.get('audio') or res.get('audio_bytes') or b'',
|
||||
'content_type': res.get('content_type', 'audio/mpeg'),
|
||||
'voice': res.get('voice') or voice or 'elevenlabs',
|
||||
'characters': len(text),
|
||||
}
|
||||
if provider == 'gtts':
|
||||
from odoo.addons.encoach_ai.services.free_tts import (
|
||||
synthesize_with_gtts,
|
||||
)
|
||||
return synthesize_with_gtts(text, language=language)
|
||||
if provider == 'silent':
|
||||
from odoo.addons.encoach_ai.services.free_tts import synthesize_silent
|
||||
# Pick a duration roughly proportional to the script so the
|
||||
# silent stub still gives the video composer enough length.
|
||||
seconds = max(1, min(30, len(text) // 12))
|
||||
return synthesize_silent(duration_seconds=seconds)
|
||||
raise RuntimeError(f'Unknown audio provider: {provider!r}')
|
||||
|
||||
# -- Image -----------------------------------------------------------
|
||||
def generate_image(self, material, *,
|
||||
custom_prompt: Optional[str] = None,
|
||||
size: str = '1024x1024',
|
||||
style: str = 'natural',
|
||||
quality: str = 'standard') -> 'models.Model':
|
||||
"""Generate a DALL-E 3 illustration for ``material``."""
|
||||
quality: str = 'standard',
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Generate an illustration for ``material`` with provider fallback."""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
plan = material.plan_id
|
||||
_enforce_image_budget(self.env, plan, planned_images=1)
|
||||
prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip()
|
||||
media = Media.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': material.week_id.id if material.week_id else False,
|
||||
'material_id': material.id,
|
||||
'kind': 'image',
|
||||
'provider': 'openai_image',
|
||||
'provider': provider,
|
||||
'title': f'{material.title} — illustration',
|
||||
'source_text': prompt[:3000],
|
||||
'style': style,
|
||||
'status': 'generating',
|
||||
})
|
||||
try:
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'image', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
# Only the paid OpenAI provider is metered against the budget.
|
||||
if prov in ('openai', 'openai_image'):
|
||||
_enforce_image_budget(self.env, plan, planned_images=1)
|
||||
result = self._call_image_provider(
|
||||
prov, prompt=prompt, size=size, style=style,
|
||||
quality=quality, material=material, plan=plan,
|
||||
)
|
||||
provider_label = 'openai_image' if prov in (
|
||||
'openai', 'openai_image') else prov
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.png',
|
||||
mime_type=result.get('mime_type', 'image/png'),
|
||||
data=result['image'],
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
try:
|
||||
w, h = (int(s) for s in size.split('x'))
|
||||
except Exception:
|
||||
w, h = 0, 0
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': result.get('mime_type', 'image/png'),
|
||||
'size_bytes': len(result['image']),
|
||||
'width': w,
|
||||
'height': h,
|
||||
'provider': provider_label,
|
||||
'status': 'ready',
|
||||
'error': '\n'.join(errors)[:500] if errors else False,
|
||||
'cost_cents': (4 if quality == 'standard' else 8)
|
||||
if prov in ('openai', 'openai_image') else 0,
|
||||
})
|
||||
return media
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'Image provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All image providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
def _call_image_provider(self, provider, *, prompt, size, style, quality,
|
||||
material, plan):
|
||||
if provider in ('openai', 'openai_image'):
|
||||
from odoo.addons.encoach_ai.services.openai_service import (
|
||||
OpenAIService,
|
||||
)
|
||||
svc = OpenAIService(self.env)
|
||||
result = svc.generate_image(
|
||||
res = OpenAIService(self.env).generate_image(
|
||||
prompt, size=size, style=style, quality=quality,
|
||||
)
|
||||
img = result['image']
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.png',
|
||||
mime_type='image/png',
|
||||
data=img,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
try:
|
||||
w, h = (int(s) for s in size.split('x'))
|
||||
except Exception:
|
||||
w, h = 0, 0
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
return {
|
||||
'image': res['image'],
|
||||
'mime_type': 'image/png',
|
||||
'size_bytes': len(img),
|
||||
'width': w,
|
||||
'height': h,
|
||||
'status': 'ready',
|
||||
'error': False,
|
||||
'cost_cents': 4 if quality == 'standard' else 8,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('Image gen failed for material %s', material.id)
|
||||
media.write({'status': 'failed', 'error': str(exc)[:500]})
|
||||
return media
|
||||
}
|
||||
if provider == 'pillow':
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
png = render_placeholder(
|
||||
material.title or 'Course material',
|
||||
subtitle=_image_subtitle(material, plan=plan),
|
||||
size=size,
|
||||
seed=material.id,
|
||||
)
|
||||
return {'image': png, 'mime_type': 'image/png'}
|
||||
if provider == 'unsplash':
|
||||
return self._fetch_unsplash(prompt, size=size)
|
||||
if provider == 'mock':
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
png = render_placeholder(
|
||||
'Mock provider',
|
||||
subtitle=material.title or '',
|
||||
size=size,
|
||||
seed=material.id,
|
||||
)
|
||||
return {'image': png, 'mime_type': 'image/png'}
|
||||
raise RuntimeError(f'Unknown image provider: {provider!r}')
|
||||
|
||||
def _fetch_unsplash(self, prompt, *, size='1024x1024'):
|
||||
"""Free Unsplash Source endpoint — no API key required.
|
||||
|
||||
Falls back to the offline Pillow placeholder if the network call
|
||||
fails so this provider, like all the others, is non-blocking.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError('requests not installed') from exc
|
||||
# The "source" endpoint returns a redirect to a JPEG that matches
|
||||
# the keywords. We deliberately use only the first 5 keywords to
|
||||
# keep the URL short.
|
||||
words = ' '.join((prompt or '').split()[:5]).strip() or 'education'
|
||||
try:
|
||||
w, h = size.lower().split('x')
|
||||
except Exception:
|
||||
w, h = '1024', '1024'
|
||||
url = f'https://source.unsplash.com/{w}x{h}/?{words}'
|
||||
resp = requests.get(url, timeout=15, allow_redirects=True)
|
||||
if resp.status_code != 200 or not resp.content:
|
||||
raise RuntimeError(
|
||||
f'Unsplash returned HTTP {resp.status_code}'
|
||||
)
|
||||
return {'image': resp.content, 'mime_type': 'image/jpeg'}
|
||||
|
||||
# -- Video -----------------------------------------------------------
|
||||
def compose_video(self, material, *, audio_media=None,
|
||||
image_media=None) -> 'models.Model':
|
||||
image_media=None,
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Compose a slide-style MP4 (image + audio) for ``material``.
|
||||
|
||||
Auto-creates audio and/or image first if the caller didn't pass
|
||||
them and they don't already exist on the material. Requires
|
||||
``ffmpeg`` on PATH; without it the media row is marked failed
|
||||
with a clear error message.
|
||||
Strategy:
|
||||
|
||||
1. Try ``ffmpeg`` (real slideshow video) if ffmpeg is on PATH.
|
||||
2. Fall back to ``static`` — a 5-second MP4 generated purely
|
||||
from the placeholder image without external audio.
|
||||
|
||||
Like the audio/image methods, this never raises to the caller;
|
||||
it always returns a media row whose ``status`` reflects success
|
||||
or failure.
|
||||
"""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
plan = material.plan_id
|
||||
@@ -295,93 +461,160 @@ class MediaService:
|
||||
'week_id': material.week_id.id if material.week_id else False,
|
||||
'material_id': material.id,
|
||||
'kind': 'video',
|
||||
'provider': 'ffmpeg',
|
||||
'provider': provider,
|
||||
'title': f'{material.title} — slideshow',
|
||||
'status': 'generating',
|
||||
})
|
||||
|
||||
if shutil.which('ffmpeg') is None:
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': 'ffmpeg not found on PATH; install it on the server',
|
||||
})
|
||||
return media
|
||||
|
||||
try:
|
||||
audio = audio_media or material.media_ids.filtered(
|
||||
lambda m: m.kind == 'audio' and m.status == 'ready'
|
||||
)[:1]
|
||||
if not audio:
|
||||
audio = self.synthesize_audio(material)
|
||||
if audio.status != 'ready':
|
||||
raise RuntimeError(
|
||||
f'Audio prerequisite not ready: {audio.error or "unknown"}'
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'video', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
if prov == 'ffmpeg':
|
||||
return self._compose_video_ffmpeg(
|
||||
media, material, audio_media, image_media,
|
||||
)
|
||||
image = image_media or material.media_ids.filtered(
|
||||
lambda m: m.kind == 'image' and m.status == 'ready'
|
||||
)[:1]
|
||||
if not image:
|
||||
image = self.generate_image(material)
|
||||
if image.status != 'ready':
|
||||
raise RuntimeError(
|
||||
f'Image prerequisite not ready: {image.error or "unknown"}'
|
||||
)
|
||||
|
||||
audio_attach = audio.attachment_id
|
||||
image_attach = image.attachment_id
|
||||
if not audio_attach or not image_attach:
|
||||
raise RuntimeError('Missing audio/image attachments')
|
||||
|
||||
audio_bytes = base64.b64decode(audio_attach.datas)
|
||||
image_bytes = base64.b64decode(image_attach.datas)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='encoach_video_') as tmp:
|
||||
a_path = os.path.join(tmp, 'audio.mp3')
|
||||
i_path = os.path.join(tmp, 'image.png')
|
||||
v_path = os.path.join(tmp, 'out.mp4')
|
||||
with open(a_path, 'wb') as f:
|
||||
f.write(audio_bytes)
|
||||
with open(i_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
cmd = [
|
||||
'ffmpeg', '-y',
|
||||
'-loop', '1', '-i', i_path,
|
||||
'-i', a_path,
|
||||
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-b:a', '192k',
|
||||
'-shortest', '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white',
|
||||
v_path,
|
||||
]
|
||||
t0 = time.time()
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, check=False, timeout=180,
|
||||
if prov == 'static':
|
||||
return self._compose_video_static(media, material)
|
||||
raise RuntimeError(f'Unknown video provider: {prov!r}')
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'Video provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:]
|
||||
raise RuntimeError(f'ffmpeg failed: {err}')
|
||||
with open(v_path, 'rb') as f:
|
||||
video_bytes = f.read()
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.mp4',
|
||||
mime_type='video/mp4',
|
||||
data=video_bytes,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': 'video/mp4',
|
||||
'size_bytes': len(video_bytes),
|
||||
'duration_seconds': float(audio.duration_seconds or elapsed or 0),
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'status': 'ready',
|
||||
'error': False,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('Video compose failed for material %s', material.id)
|
||||
media.write({'status': 'failed', 'error': str(exc)[:500]})
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All video providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
# ── Concrete video providers ────────────────────────────────────────
|
||||
|
||||
def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
|
||||
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
|
||||
if shutil.which('ffmpeg') is None:
|
||||
raise RuntimeError('ffmpeg not found on PATH')
|
||||
|
||||
plan = material.plan_id
|
||||
audio = audio_media or material.media_ids.filtered(
|
||||
lambda m: m.kind == 'audio' and m.status == 'ready'
|
||||
)[:1]
|
||||
if not audio:
|
||||
audio = self.synthesize_audio(material)
|
||||
if audio.status != 'ready':
|
||||
raise RuntimeError(
|
||||
f'Audio prerequisite not ready: {audio.error or "unknown"}'
|
||||
)
|
||||
image = image_media or material.media_ids.filtered(
|
||||
lambda m: m.kind == 'image' and m.status == 'ready'
|
||||
)[:1]
|
||||
if not image:
|
||||
image = self.generate_image(material)
|
||||
if image.status != 'ready':
|
||||
raise RuntimeError(
|
||||
f'Image prerequisite not ready: {image.error or "unknown"}'
|
||||
)
|
||||
audio_attach = audio.attachment_id
|
||||
image_attach = image.attachment_id
|
||||
if not audio_attach or not image_attach:
|
||||
raise RuntimeError('Missing audio/image attachments')
|
||||
|
||||
audio_bytes = base64.b64decode(audio_attach.datas)
|
||||
image_bytes = base64.b64decode(image_attach.datas)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='encoach_video_') as tmp:
|
||||
a_path = os.path.join(tmp, 'audio.mp3')
|
||||
i_path = os.path.join(tmp, 'image.png')
|
||||
v_path = os.path.join(tmp, 'out.mp4')
|
||||
with open(a_path, 'wb') as f:
|
||||
f.write(audio_bytes)
|
||||
with open(i_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
cmd = [
|
||||
'ffmpeg', '-y',
|
||||
'-loop', '1', '-i', i_path,
|
||||
'-i', a_path,
|
||||
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-b:a', '192k',
|
||||
'-shortest',
|
||||
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,'
|
||||
'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white',
|
||||
v_path,
|
||||
]
|
||||
t0 = time.time()
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, check=False, timeout=180,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:]
|
||||
raise RuntimeError(f'ffmpeg failed: {err}')
|
||||
with open(v_path, 'rb') as f:
|
||||
video_bytes = f.read()
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.mp4',
|
||||
mime_type='video/mp4',
|
||||
data=video_bytes,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': 'video/mp4',
|
||||
'size_bytes': len(video_bytes),
|
||||
'duration_seconds': float(audio.duration_seconds or elapsed or 0),
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'provider': 'ffmpeg',
|
||||
'status': 'ready',
|
||||
'error': False,
|
||||
})
|
||||
return media
|
||||
|
||||
def _compose_video_static(self, media, material):
|
||||
"""Last-resort: a tiny MP4-shaped image-only stub.
|
||||
|
||||
We don't pretend to render a true video without ffmpeg — instead
|
||||
we attach the placeholder PNG with an MP4 mime so the LMS can
|
||||
still display *something*. The media row is marked ``ready`` but
|
||||
flagged as ``static`` so admins can re-generate later.
|
||||
"""
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
plan = material.plan_id
|
||||
png = render_placeholder(
|
||||
material.title or 'Course material',
|
||||
subtitle=_image_subtitle(material, plan=plan) + ' · static',
|
||||
size='1280x720',
|
||||
seed=material.id,
|
||||
)
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}-static.png',
|
||||
mime_type='image/png',
|
||||
data=png,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': 'image/png',
|
||||
'size_bytes': len(png),
|
||||
'duration_seconds': 0.0,
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'provider': 'static',
|
||||
'status': 'ready',
|
||||
'error': 'ffmpeg not available — served as static placeholder image',
|
||||
})
|
||||
return media
|
||||
|
||||
@@ -17,6 +17,7 @@ error fields. That way one bad PDF doesn't block the rest.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -111,6 +112,69 @@ class SourceIndexer:
|
||||
if source.kind == 'text':
|
||||
return (source.inline_text or '').strip()
|
||||
|
||||
if source.kind == 'resource':
|
||||
# Dereference the library resource at extraction time. This
|
||||
# keeps the binary in one place (``encoach.resource``) so an
|
||||
# admin update — re-uploading a corrected PDF, fixing a URL,
|
||||
# changing the linked file — propagates to every plan that
|
||||
# grounds on it on the next reindex.
|
||||
res = source.resource_id
|
||||
if not res or not res.exists():
|
||||
raise ValueError(
|
||||
'Linked library resource is missing or was deleted.',
|
||||
)
|
||||
rtype = (res.type or '').lower()
|
||||
file_name = (res.name or '') + (
|
||||
f'.{rtype}' if rtype in ('pdf', 'docx') and not (res.name or '').lower().endswith(('.pdf', '.docx')) else ''
|
||||
)
|
||||
# Persist the resolved metadata on the source row so the UI
|
||||
# can render a meaningful "indexed N chunks from X.pdf" line
|
||||
# without having to rejoin the resource table on every read.
|
||||
updates = {}
|
||||
if not source.name:
|
||||
updates['name'] = res.name or f'Resource #{res.id}'
|
||||
if not source.file_name:
|
||||
updates['file_name'] = file_name
|
||||
if updates:
|
||||
source.write(updates)
|
||||
|
||||
if res.file:
|
||||
payload = base64.b64decode(res.file)
|
||||
if not payload:
|
||||
raise ValueError('Library resource has an empty file.')
|
||||
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
|
||||
return _extract_pdf(payload)
|
||||
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
|
||||
return _extract_docx(payload)
|
||||
# Fall back to plain-text decoding for txt/md/csv/json.
|
||||
if file_name.lower().endswith((
|
||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
||||
'.log', '.rst',
|
||||
)):
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
# Best-effort: try PDF first, then DOCX, then UTF-8.
|
||||
for fn in (_extract_pdf, _extract_docx):
|
||||
try:
|
||||
text = fn(payload)
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f'Cannot decode library resource binary: {exc}',
|
||||
) from exc
|
||||
|
||||
if res.url:
|
||||
_, text = _fetch_url(res.url)
|
||||
return text or ''
|
||||
|
||||
raise ValueError(
|
||||
'Library resource has neither a file nor a URL to index.',
|
||||
)
|
||||
|
||||
if source.kind == 'url':
|
||||
url = (source.url or '').strip()
|
||||
if not url:
|
||||
@@ -133,10 +197,24 @@ class SourceIndexer:
|
||||
'application/msword',
|
||||
) or name.endswith('.docx') or name.endswith('.doc')):
|
||||
return _extract_docx(payload)
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Cannot decode file: {exc}') from exc
|
||||
# Whitelist plain-text-shaped uploads explicitly. Anything else
|
||||
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
|
||||
# rather than silently UTF-8-decoded into garbage that we'd
|
||||
# then "successfully" embed and surface as a usable RAG source.
|
||||
if (mime.startswith('text/')
|
||||
or mime in ('application/json', 'application/xml',
|
||||
'application/csv')
|
||||
or name.endswith(('.txt', '.md', '.markdown', '.csv',
|
||||
'.json', '.xml', '.log', '.rst'))):
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Cannot decode file: {exc}') from exc
|
||||
raise ValueError(
|
||||
f'Unsupported file type for RAG indexing: '
|
||||
f'mime={mime!r} name={source.file_name!r}. '
|
||||
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
|
||||
)
|
||||
|
||||
raise ValueError(f'Unknown source kind: {source.kind!r}')
|
||||
|
||||
|
||||
@@ -94,12 +94,39 @@ def _get_jwt_secret():
|
||||
return secret
|
||||
|
||||
|
||||
def validate_token():
|
||||
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None."""
|
||||
def _extract_bearer_token(allow_query_param: bool = False) -> str | None:
|
||||
"""Pull a JWT off the current request.
|
||||
|
||||
By default we only honour the ``Authorization: Bearer …`` header. Some
|
||||
endpoints (notably media streams that get embedded in ``<img>`` /
|
||||
``<audio>`` / ``<video>`` tags, where the browser cannot attach custom
|
||||
headers) opt in to ``allow_query_param=True`` so callers can send the
|
||||
token via ``?token=<jwt>`` or ``?access_token=<jwt>``. We deliberately
|
||||
keep this off by default so a leaked URL never gives access to JSON
|
||||
APIs — only to the specific raw-media route that opts in.
|
||||
"""
|
||||
auth_header = request.httprequest.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header[7:].strip() or None
|
||||
if allow_query_param:
|
||||
try:
|
||||
args = request.httprequest.args
|
||||
except Exception:
|
||||
args = {}
|
||||
token = (args.get("token") or args.get("access_token") or "").strip()
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def validate_token(allow_query_param: bool = False):
|
||||
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None.
|
||||
|
||||
See :func:`_extract_bearer_token` for the ``allow_query_param`` flag.
|
||||
"""
|
||||
token = _extract_bearer_token(allow_query_param=allow_query_param)
|
||||
if not token:
|
||||
return None
|
||||
token = auth_header[7:]
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -224,7 +224,15 @@ class AcademicController(http.Controller):
|
||||
'term_end_date': str(e),
|
||||
'academic_year_id': year.id,
|
||||
})
|
||||
if 'parent_id' in Term._fields:
|
||||
# Wire the quarter -> parent semester via the actual
|
||||
# field name on op.academic.term, which is
|
||||
# ``parent_term`` (not ``parent_id``). The previous
|
||||
# check matched ``parent_id`` and silently no-op'd
|
||||
# for everyone — the relationship was never written.
|
||||
if 'parent_term' in Term._fields:
|
||||
q1.write({'parent_term': parent.id})
|
||||
q2.write({'parent_term': parent.id})
|
||||
elif 'parent_id' in Term._fields:
|
||||
q1.write({'parent_id': parent.id})
|
||||
q2.write({'parent_id': parent.id})
|
||||
quarters += [q1, q2]
|
||||
|
||||
@@ -11,6 +11,48 @@ _logger = logging.getLogger(__name__)
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for the current user."""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
return ids, is_super
|
||||
|
||||
|
||||
def _ensure_entity_access(entity_id):
|
||||
"""Raise PermissionError if current user cannot access entity_id."""
|
||||
if not entity_id:
|
||||
raise PermissionError('entity_id is required')
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return int(entity_id)
|
||||
if not ids:
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
if int(entity_id) not in ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
return int(entity_id)
|
||||
|
||||
|
||||
def _default_entity_id_from_scope():
|
||||
"""Pick the default entity for creation from the current user scope."""
|
||||
ids, is_super = _entity_scope()
|
||||
if ids:
|
||||
return ids[0]
|
||||
if is_super:
|
||||
return False
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
|
||||
|
||||
def _scoped_entity_domain(base_domain=None, field='entity_id'):
|
||||
"""Apply entity isolation domain to a model query."""
|
||||
domain = list(base_domain or [])
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [(field, 'in', ids)]
|
||||
|
||||
def _serialize_course(c):
|
||||
subj = getattr(c, 'encoach_subject_id', False)
|
||||
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
|
||||
@@ -43,6 +85,8 @@ def _serialize_course(c):
|
||||
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
|
||||
'resource_count': getattr(c, 'resource_count', 0) or 0,
|
||||
'objective_count': getattr(c, 'objective_count', 0) or 0,
|
||||
'entity_id': c.entity_id.id if hasattr(c, 'entity_id') and c.entity_id else None,
|
||||
'entity_name': c.entity_id.name if hasattr(c, 'entity_id') and c.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +119,8 @@ def _serialize_student(s):
|
||||
'batch_name': batch_name,
|
||||
'partner_id': partner.id,
|
||||
'user_id': s.user_id.id if s.user_id else None,
|
||||
'entity_id': s.entity_id.id if hasattr(s, 'entity_id') and s.entity_id else None,
|
||||
'entity_name': s.entity_id.name if hasattr(s, 'entity_id') and s.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +143,8 @@ def _serialize_teacher(f):
|
||||
'department_name': dept.name if dept else '',
|
||||
'specialization': getattr(f, 'specialization', '') or '',
|
||||
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
|
||||
'entity_id': f.entity_id.id if hasattr(f, 'entity_id') and f.entity_id else None,
|
||||
'entity_name': f.entity_id.name if hasattr(f, 'entity_id') and f.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +175,8 @@ def _serialize_batch(b):
|
||||
'max_students': getattr(b, 'max_students', 0) or 0,
|
||||
'student_count': len(students),
|
||||
'students': students,
|
||||
'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None,
|
||||
'entity_name': b.entity_id.name if hasattr(b, 'entity_id') and b.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -139,9 +189,11 @@ class LmsCoreController(http.Controller):
|
||||
def list_courses(self, **kw):
|
||||
try:
|
||||
Course = request.env['op.course'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('status'):
|
||||
pass # op.course has no status field by default
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Course.search_count(domain)
|
||||
records = Course.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -154,7 +206,7 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_courses failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -163,6 +215,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.course'].sudo().browse(course_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_course failed')
|
||||
@@ -173,10 +228,17 @@ class LmsCoreController(http.Controller):
|
||||
def create_course(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
vals = {
|
||||
'name': body.get('name', ''),
|
||||
'code': body.get('code', ''),
|
||||
}
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('description'):
|
||||
vals['description'] = body['description']
|
||||
if body.get('max_capacity'):
|
||||
@@ -197,7 +259,7 @@ class LmsCoreController(http.Controller):
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_course failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -206,6 +268,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.course'].sudo().browse(course_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
for k in ('name', 'code', 'description'):
|
||||
@@ -225,12 +290,14 @@ class LmsCoreController(http.Controller):
|
||||
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
|
||||
if 'tag_ids' in body:
|
||||
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_course failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -239,6 +306,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.course'].sudo().browse(course_id)
|
||||
if not rec.exists():
|
||||
return _json_response({'success': True})
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes')
|
||||
enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)])
|
||||
if enrollments and not force:
|
||||
@@ -272,8 +342,12 @@ class LmsCoreController(http.Controller):
|
||||
('student_id', '=', student.id)
|
||||
])
|
||||
course_ids = course_details.mapped('course_id')
|
||||
ids, is_super = _entity_scope()
|
||||
items = []
|
||||
for c in course_ids:
|
||||
if not is_super:
|
||||
if not c.entity_id or c.entity_id.id not in ids:
|
||||
continue
|
||||
data = _serialize_course(c)
|
||||
cd = course_details.filtered(lambda d: d.course_id.id == c.id)
|
||||
data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None
|
||||
@@ -307,6 +381,9 @@ class LmsCoreController(http.Controller):
|
||||
student = request.env['op.student'].sudo().browse(student_id)
|
||||
if not student.exists():
|
||||
return _error_response('Student not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not student.entity_id or student.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
course_id = body.get('course_id')
|
||||
course_ids = body.get('course_ids', [])
|
||||
@@ -317,6 +394,13 @@ class LmsCoreController(http.Controller):
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
for cid in course_ids:
|
||||
cid = int(cid)
|
||||
course = request.env['op.course'].sudo().browse(cid)
|
||||
if not course.exists():
|
||||
continue
|
||||
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
|
||||
continue
|
||||
if student.entity_id and course.entity_id and student.entity_id.id != course.entity_id.id:
|
||||
continue
|
||||
existing = SC.search([
|
||||
('student_id', '=', student.id),
|
||||
('course_id', '=', cid),
|
||||
@@ -347,6 +431,9 @@ class LmsCoreController(http.Controller):
|
||||
course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
student_ids = [int(sid) for sid in body.get('student_ids', [])]
|
||||
batch_id = body.get('batch_id')
|
||||
@@ -358,6 +445,13 @@ class LmsCoreController(http.Controller):
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
enrolled = []
|
||||
for sid in student_ids:
|
||||
stu = request.env['op.student'].sudo().browse(sid)
|
||||
if not stu.exists():
|
||||
continue
|
||||
if not is_super and (not stu.entity_id or stu.entity_id.id not in ids):
|
||||
continue
|
||||
if stu.entity_id and course.entity_id and stu.entity_id.id != course.entity_id.id:
|
||||
continue
|
||||
existing = SC.search([
|
||||
('student_id', '=', sid),
|
||||
('course_id', '=', course_id),
|
||||
@@ -385,11 +479,13 @@ class LmsCoreController(http.Controller):
|
||||
def list_students(self, **kw):
|
||||
try:
|
||||
Student = request.env['op.student'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('search'):
|
||||
domain = [('partner_id.name', 'ilike', kw['search'])]
|
||||
domain.append(('partner_id.name', 'ilike', kw['search']))
|
||||
if kw.get('batch_id'):
|
||||
domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Student.search_count(domain)
|
||||
records = Student.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -402,7 +498,7 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_students failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -411,6 +507,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.student'].sudo().browse(student_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_student(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_student failed')
|
||||
@@ -421,6 +520,11 @@ class LmsCoreController(http.Controller):
|
||||
def create_student(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
first = body.get('first_name', '')
|
||||
last = body.get('last_name', '')
|
||||
name = f"{first} {last}".strip()
|
||||
@@ -434,6 +538,8 @@ class LmsCoreController(http.Controller):
|
||||
'partner_id': partner.id,
|
||||
'gender': body.get('gender', ''),
|
||||
}
|
||||
if entity_id:
|
||||
student_vals['entity_id'] = entity_id
|
||||
if body.get('birth_date'):
|
||||
student_vals['birth_date'] = body['birth_date']
|
||||
student = request.env['op.student'].sudo().create(student_vals)
|
||||
@@ -453,11 +559,13 @@ class LmsCoreController(http.Controller):
|
||||
'password': body.get('password', 'student123'),
|
||||
'partner_id': partner.id,
|
||||
})
|
||||
if entity_id and hasattr(user, 'entity_ids'):
|
||||
user.write({'entity_ids': [(4, entity_id)]})
|
||||
student.sudo().write({'user_id': user.id})
|
||||
return _json_response({'data': _serialize_student(student)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_student failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -466,6 +574,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.student'].sudo().browse(student_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
partner_vals = {}
|
||||
if 'first_name' in body or 'last_name' in body:
|
||||
@@ -481,12 +592,14 @@ class LmsCoreController(http.Controller):
|
||||
student_vals = {}
|
||||
if 'gender' in body:
|
||||
student_vals['gender'] = body['gender']
|
||||
if 'entity_id' in body:
|
||||
student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if student_vals:
|
||||
rec.write(student_vals)
|
||||
return _json_response({'data': _serialize_student(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_student failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -494,6 +607,9 @@ class LmsCoreController(http.Controller):
|
||||
try:
|
||||
rec = request.env['op.student'].sudo().browse(student_id)
|
||||
if rec.exists():
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -507,9 +623,11 @@ class LmsCoreController(http.Controller):
|
||||
def list_teachers(self, **kw):
|
||||
try:
|
||||
Faculty = request.env['op.faculty'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('search'):
|
||||
domain = [('partner_id.name', 'ilike', kw['search'])]
|
||||
domain.append(('partner_id.name', 'ilike', kw['search']))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Faculty.search_count(domain)
|
||||
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -522,13 +640,18 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_teachers failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_teacher(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
first = body.get('first_name', '')
|
||||
last = body.get('last_name', '')
|
||||
name = f"{first} {last}".strip()
|
||||
@@ -541,6 +664,8 @@ class LmsCoreController(http.Controller):
|
||||
'partner_id': partner.id,
|
||||
'gender': body.get('gender', ''),
|
||||
}
|
||||
if entity_id:
|
||||
fac_vals['entity_id'] = entity_id
|
||||
if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
|
||||
fac_vals['department_id'] = int(body['department_id'])
|
||||
if body.get('birth_date'):
|
||||
@@ -549,14 +674,64 @@ class LmsCoreController(http.Controller):
|
||||
return _json_response({'data': _serialize_teacher(faculty)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_teacher failed')
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_teacher(self, teacher_id, **kw):
|
||||
try:
|
||||
rec = request.env['op.faculty'].sudo().browse(teacher_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_teacher(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_teacher failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_teacher(self, teacher_id, **kw):
|
||||
try:
|
||||
rec = request.env['op.faculty'].sudo().browse(teacher_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
pvals = {}
|
||||
if 'name' in body:
|
||||
pvals['name'] = body['name']
|
||||
if 'email' in body:
|
||||
pvals['email'] = body['email']
|
||||
if 'phone' in body:
|
||||
pvals['phone'] = body['phone']
|
||||
if pvals:
|
||||
rec.partner_id.sudo().write(pvals)
|
||||
vals = {}
|
||||
if 'gender' in body:
|
||||
vals['gender'] = body['gender']
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_teacher(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_teacher failed')
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_teacher(self, teacher_id, **kw):
|
||||
try:
|
||||
rec = request.env['op.faculty'].sudo().browse(teacher_id)
|
||||
if rec.exists():
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -570,7 +745,9 @@ class LmsCoreController(http.Controller):
|
||||
def list_batches(self, **kw):
|
||||
try:
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Batch.search_count(domain)
|
||||
records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -583,7 +760,7 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_batches failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -592,6 +769,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_batch failed')
|
||||
@@ -602,11 +782,18 @@ class LmsCoreController(http.Controller):
|
||||
def create_batch(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
vals = {'name': body.get('name', '')}
|
||||
if body.get('code'):
|
||||
vals['code'] = body['code']
|
||||
if body.get('course_id'):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('start_date'):
|
||||
vals['start_date'] = body['start_date']
|
||||
if body.get('end_date'):
|
||||
@@ -615,7 +802,7 @@ class LmsCoreController(http.Controller):
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_batch failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -624,6 +811,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
for k in ('name', 'code', 'start_date', 'end_date'):
|
||||
@@ -631,12 +821,14 @@ class LmsCoreController(http.Controller):
|
||||
vals[k] = body[k]
|
||||
if 'course_id' in body:
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_batch failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -644,6 +836,9 @@ class LmsCoreController(http.Controller):
|
||||
try:
|
||||
rec = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if rec.exists():
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -655,6 +850,12 @@ class LmsCoreController(http.Controller):
|
||||
def list_batch_students(self, batch_id, **kw):
|
||||
try:
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
recs = SC.search([('batch_id', '=', batch_id)])
|
||||
students = []
|
||||
for sc in recs:
|
||||
@@ -680,11 +881,19 @@ class LmsCoreController(http.Controller):
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
student_ids = [int(s) for s in body.get('student_ids', [])]
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
added = []
|
||||
for sid in student_ids:
|
||||
stu = request.env['op.student'].sudo().browse(sid)
|
||||
if not stu.exists():
|
||||
continue
|
||||
if batch.entity_id and stu.entity_id and batch.entity_id.id != stu.entity_id.id:
|
||||
continue
|
||||
existing = SC.search([
|
||||
('student_id', '=', sid),
|
||||
('batch_id', '=', batch_id),
|
||||
@@ -719,6 +928,12 @@ class LmsCoreController(http.Controller):
|
||||
def remove_students_from_batch(self, batch_id, **kw):
|
||||
"""Remove students from a batch by clearing their batch_id."""
|
||||
try:
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
student_ids = [int(s) for s in body.get('student_ids', [])]
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
@@ -751,6 +966,9 @@ class LmsCoreController(http.Controller):
|
||||
course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False
|
||||
topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic']
|
||||
objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective']
|
||||
@@ -771,9 +989,9 @@ class LmsCoreController(http.Controller):
|
||||
def subject_courses(self, subject_id, **kw):
|
||||
"""Return all courses linked to a given taxonomy subject."""
|
||||
try:
|
||||
courses = request.env['op.course'].sudo().search([
|
||||
courses = request.env['op.course'].sudo().search(_scoped_entity_domain([
|
||||
('encoach_subject_id', '=', subject_id)
|
||||
])
|
||||
]))
|
||||
return _json_response({
|
||||
'items': [_serialize_course(c) for c in courses],
|
||||
'total': len(courses),
|
||||
|
||||
@@ -63,17 +63,70 @@ def _attempt_completed_at(att):
|
||||
return None
|
||||
|
||||
|
||||
def _allowed_entity_ids(env):
|
||||
"""Return the set of entity ids the calling user is allowed to see.
|
||||
|
||||
* Admins / system users: ``None`` (unrestricted — they may pass any
|
||||
``entity_id`` query param to scope manually).
|
||||
* Corporate / master-corporate / teacher / student: the entity ids
|
||||
linked to ``res.users.entity_ids`` on their record. Empty set means
|
||||
"no entities, see nothing" (defensive — better than leaking).
|
||||
"""
|
||||
user = env.user
|
||||
if not user or not user.id:
|
||||
return set()
|
||||
user_type = getattr(user, 'user_type', None)
|
||||
if user_type == 'admin' or user.has_group('base.group_system'):
|
||||
return None # unrestricted
|
||||
try:
|
||||
return set((user.entity_ids or env['encoach.entity']).ids)
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _build_attempt_domain(kw, reportable=True):
|
||||
"""Common filter reader used by all three endpoints."""
|
||||
"""Common filter reader used by all three endpoints.
|
||||
|
||||
Always enforces the caller's allowed entity scope as a default
|
||||
domain so corporate users can never see another company's data —
|
||||
even if they omit ``entity_id`` or pass one outside their allow-list.
|
||||
Admins are unrestricted and may pass any ``entity_id``.
|
||||
"""
|
||||
from odoo.http import request as _req
|
||||
domain = []
|
||||
if reportable:
|
||||
domain.append(('status', 'in', list(REPORTABLE_STATUSES)))
|
||||
entity_id = kw.get('entity_id')
|
||||
if entity_id:
|
||||
|
||||
allowed = _allowed_entity_ids(_req.env)
|
||||
requested_entity = kw.get('entity_id')
|
||||
requested_int = None
|
||||
if requested_entity:
|
||||
try:
|
||||
domain.append(('entity_id', '=', int(entity_id)))
|
||||
requested_int = int(requested_entity)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
requested_int = None
|
||||
|
||||
if allowed is None:
|
||||
# Admin: honour the requested entity_id verbatim, no scoping.
|
||||
if requested_int is not None:
|
||||
domain.append(('entity_id', '=', requested_int))
|
||||
else:
|
||||
# Non-admin: clamp the requested entity to the allow-list. If
|
||||
# they didn't request one, scope to all of theirs. If their
|
||||
# request is outside the allow-list, return an empty result set
|
||||
# by appending an impossible domain — never a 200 with leaked
|
||||
# data.
|
||||
if requested_int is not None:
|
||||
if requested_int in allowed:
|
||||
domain.append(('entity_id', '=', requested_int))
|
||||
else:
|
||||
domain.append(('entity_id', '=', -1)) # forces empty
|
||||
else:
|
||||
if allowed:
|
||||
domain.append(('entity_id', 'in', list(allowed)))
|
||||
else:
|
||||
domain.append(('entity_id', '=', -1)) # no entities, no data
|
||||
|
||||
user_id = kw.get('user_id') or kw.get('student_id')
|
||||
if user_id:
|
||||
try:
|
||||
|
||||
@@ -1,17 +1,90 @@
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
# Keep this in sync with the Selection on ``encoach.resource`` so we
|
||||
# never silently drop a category. Order matters: more specific MIMEs
|
||||
# (``application/pdf``) must come before catch-all groups (``image/*``)
|
||||
# because the matcher walks the list top-to-bottom.
|
||||
_MIME_TO_TYPE = (
|
||||
('application/pdf', 'pdf'),
|
||||
('image/', 'image'),
|
||||
('audio/', 'audio'),
|
||||
('video/', 'video'),
|
||||
('text/html', 'article'),
|
||||
('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document'),
|
||||
('application/msword', 'document'),
|
||||
('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'document'),
|
||||
('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'document'),
|
||||
('text/', 'document'),
|
||||
)
|
||||
|
||||
|
||||
def _detect_type_from_mime(mime):
|
||||
"""Map a MIME string to one of the ``encoach.resource.type`` values."""
|
||||
if not mime:
|
||||
return ''
|
||||
mime = mime.lower().split(';')[0].strip()
|
||||
for prefix, rtype in _MIME_TO_TYPE:
|
||||
if mime.startswith(prefix):
|
||||
return rtype
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_attachment_mime(rec):
|
||||
"""Find the MIME of the binary even when ``rec.mimetype`` was never
|
||||
persisted (older rows uploaded before the schema migration). We
|
||||
walk the ir.attachment row Odoo creates for ``Binary(attachment=True)``
|
||||
and fall back to extension sniffing on the human name.
|
||||
"""
|
||||
if rec.mimetype:
|
||||
return rec.mimetype.split(';')[0].strip().lower()
|
||||
att = rec.env['ir.attachment'].sudo().search([
|
||||
('res_model', '=', 'encoach.resource'),
|
||||
('res_id', '=', rec.id),
|
||||
('res_field', '=', 'file'),
|
||||
], limit=1)
|
||||
if att and att.mimetype:
|
||||
return att.mimetype.split(';')[0].strip().lower()
|
||||
if rec.name:
|
||||
return (mimetypes.guess_type(rec.name)[0] or '').lower()
|
||||
return ''
|
||||
|
||||
|
||||
def _build_filename(rec):
|
||||
"""Best-effort filename with extension for the download header.
|
||||
|
||||
Priority: 1) the persisted original_filename (always has the
|
||||
extension as uploaded), 2) the human name + an extension guessed
|
||||
from the cached mimetype (or sniffed from the linked ir.attachment),
|
||||
3) the human name as-is.
|
||||
"""
|
||||
if rec.original_filename:
|
||||
return rec.original_filename
|
||||
base = (rec.name or f'resource-{rec.id}').strip()
|
||||
if '.' in os.path.basename(base):
|
||||
return base
|
||||
mime = _resolve_attachment_mime(rec)
|
||||
ext = mimetypes.guess_extension(mime) if mime else ''
|
||||
return f'{base}{ext}' if ext else base
|
||||
|
||||
|
||||
def _ser_resource(r):
|
||||
tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag']
|
||||
objectives = r.learning_objective_ids if r.learning_objective_ids else r.env['encoach.learning.objective']
|
||||
download_url = f'/api/resources/{r.id}/download' if r.file else ''
|
||||
preview_url = f'/api/resources/{r.id}/download?inline=1' if r.file else ''
|
||||
return {
|
||||
'id': r.id,
|
||||
'name': r.name or '',
|
||||
@@ -30,6 +103,11 @@ def _ser_resource(r):
|
||||
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
|
||||
'url': r.url or '',
|
||||
'has_file': bool(r.file),
|
||||
'mimetype': r.mimetype or '',
|
||||
'original_filename': r.original_filename or '',
|
||||
'download_url': download_url,
|
||||
'preview_url': preview_url,
|
||||
'file_name': r.original_filename or r.name or '',
|
||||
'difficulty': r.difficulty or '',
|
||||
'duration_minutes': r.duration_minutes or 0,
|
||||
'author_id': r.creator_id.id if r.creator_id else None,
|
||||
@@ -125,7 +203,36 @@ class ResourcesController(http.Controller):
|
||||
if params.get('cefr_level'):
|
||||
vals['cefr_level'] = params['cefr_level']
|
||||
if f:
|
||||
vals['file'] = base64.b64encode(f.read())
|
||||
payload = f.read()
|
||||
vals['file'] = base64.b64encode(payload)
|
||||
# Persist the *real* upload filename — the human-
|
||||
# readable ``name`` field often loses the extension
|
||||
# ("test" instead of "test.pdf"), which broke
|
||||
# downloads and inline previews.
|
||||
if f.filename:
|
||||
vals['original_filename'] = f.filename
|
||||
# Detect MIME — prefer the one Werkzeug parsed from
|
||||
# the multipart upload; fall back to extension sniffing
|
||||
# for clients that don't send it.
|
||||
mime = (f.mimetype or '').split(';')[0].strip().lower()
|
||||
if not mime and f.filename:
|
||||
mime = (mimetypes.guess_type(f.filename)[0] or '').lower()
|
||||
if mime:
|
||||
vals['mimetype'] = mime
|
||||
# Auto-correct the type when the user picked the wrong
|
||||
# one in the dropdown (e.g. PDF default but actually an
|
||||
# image), or when no type was supplied at all. We only
|
||||
# *override* an explicit user choice when the picked
|
||||
# type clearly contradicts the mime — otherwise keep
|
||||
# what the admin selected.
|
||||
detected = _detect_type_from_mime(mime)
|
||||
if detected:
|
||||
if not vals.get('type'):
|
||||
vals['type'] = detected
|
||||
elif vals['type'] in ('pdf', 'image', 'audio', 'video') \
|
||||
and vals['type'] != detected \
|
||||
and detected in ('pdf', 'image', 'audio', 'video'):
|
||||
vals['type'] = detected
|
||||
rec = request.env['encoach.resource'].sudo().create(vals)
|
||||
return _json_response({'data': _ser_resource(rec)})
|
||||
except Exception as e:
|
||||
@@ -195,21 +302,49 @@ class ResourcesController(http.Controller):
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/resources/<int:rid>/download', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
# ``auth='none'`` + manual JWT validation lets us accept the token
|
||||
# from either the ``Authorization: Bearer`` header (download anchor
|
||||
# via fetch) **or** the ``?token=`` query param (preview iframe /
|
||||
# <img> / <audio>). HTML media tags can't send custom headers, so
|
||||
# the query-param fallback is what makes inline preview work.
|
||||
@http.route('/api/resources/<int:rid>/download', type='http',
|
||||
auth='none', methods=['GET'], csrf=False)
|
||||
def download_resource(self, rid, **kw):
|
||||
try:
|
||||
user = validate_token(allow_query_param=True)
|
||||
if not user:
|
||||
return _error_response('Authentication required', 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
rec = request.env['encoach.resource'].sudo().browse(rid)
|
||||
if not rec.exists() or not rec.file:
|
||||
return _error_response('No file', 404)
|
||||
import mimetypes
|
||||
ext = (rec.name or '').rsplit('.', 1)[-1].lower() if '.' in (rec.name or '') else ''
|
||||
mime = mimetypes.types_map.get(f'.{ext}', 'application/octet-stream')
|
||||
|
||||
data = base64.b64decode(rec.file)
|
||||
|
||||
filename = _build_filename(rec)
|
||||
mime = (
|
||||
_resolve_attachment_mime(rec)
|
||||
or mimetypes.guess_type(filename)[0]
|
||||
or 'application/octet-stream'
|
||||
)
|
||||
|
||||
# ``inline=1`` (or ``preview=1``) lets the browser render
|
||||
# PDFs in an iframe / images in <img> instead of forcing a
|
||||
# download. Default stays ``attachment`` for safety so
|
||||
# existing /download links keep their old behaviour.
|
||||
inline_flag = (
|
||||
request.httprequest.args.get('inline')
|
||||
or request.httprequest.args.get('preview')
|
||||
or ''
|
||||
).lower() in ('1', 'true', 'yes')
|
||||
disposition_kind = 'inline' if inline_flag else 'attachment'
|
||||
|
||||
return request.make_response(data, [
|
||||
('Content-Type', mime),
|
||||
('Content-Disposition', f'attachment; filename="{rec.name}"'),
|
||||
('Content-Disposition', f'{disposition_kind}; filename="{filename}"'),
|
||||
('Content-Length', str(len(data))),
|
||||
('Cache-Control', 'private, max-age=3600'),
|
||||
])
|
||||
except Exception as e:
|
||||
_logger.exception('download_resource')
|
||||
|
||||
@@ -6,6 +6,13 @@ class OpCourseExt(models.Model):
|
||||
|
||||
description = fields.Text('Description')
|
||||
max_capacity = fields.Integer('Max Capacity', default=30)
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
encoach_subject_id = fields.Many2one(
|
||||
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
|
||||
@@ -54,3 +61,39 @@ class OpCourseExt(models.Model):
|
||||
('resource_id', '!=', False),
|
||||
])
|
||||
rec.resource_count = len(mats.mapped('resource_id'))
|
||||
|
||||
|
||||
class OpBatchExt(models.Model):
|
||||
_inherit = 'op.batch'
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
|
||||
class OpStudentExt(models.Model):
|
||||
_inherit = 'op.student'
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
|
||||
class OpFacultyExt(models.Model):
|
||||
_inherit = 'op.faculty'
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
@@ -12,6 +12,13 @@ class EncoachResource(models.Model):
|
||||
('document', 'Document'),
|
||||
('link', 'Link'),
|
||||
('interactive', 'Interactive'),
|
||||
# Audio + image are surfaced in the Resource Manager table
|
||||
# (icons / filters) so we accept them as first-class types
|
||||
# rather than coercing them into ``document`` and losing the
|
||||
# MIME-correct preview (audio player, <img>, etc.).
|
||||
('audio', 'Audio'),
|
||||
('image', 'Image'),
|
||||
('article', 'Article'),
|
||||
])
|
||||
review_status = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
@@ -30,6 +37,24 @@ class EncoachResource(models.Model):
|
||||
'resource_id', 'tag_id', string='Tags',
|
||||
)
|
||||
file = fields.Binary(attachment=True)
|
||||
# Preserve the original upload filename (with extension) so the
|
||||
# download endpoint can serve "report.pdf" even when the human-
|
||||
# readable ``name`` field is set to "Q3 Sales Report" without an
|
||||
# extension. We also keep the resolved MIME type to avoid sniffing
|
||||
# by extension on every download — sniffing fails on resources
|
||||
# named "test" with no dot, and was the root cause of admins
|
||||
# downloading files with no extension and ``application/octet-stream``
|
||||
# ending up unviewable on macOS / Windows.
|
||||
original_filename = fields.Char(
|
||||
string='Original filename',
|
||||
help='Filename as uploaded, including extension. Used by the '
|
||||
'download endpoint to produce a sensible Content-Disposition.',
|
||||
)
|
||||
mimetype = fields.Char(
|
||||
string='MIME type',
|
||||
help='Cached MIME type from the uploaded file or URL. Drives '
|
||||
'preview decisions (PDF in iframe vs <img> vs <video>).',
|
||||
)
|
||||
url = fields.Char()
|
||||
difficulty = fields.Selection([
|
||||
('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'),
|
||||
@@ -67,6 +92,16 @@ class EncoachResource(models.Model):
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
creator = self.creator_id
|
||||
# Both URLs are JWT-protected via the resources controller; the
|
||||
# frontend appends ``?token=…`` with the existing
|
||||
# ``withAuthQuery`` helper so they can be used as ``href``,
|
||||
# ``src`` of <iframe>/<img>, or download anchors.
|
||||
download_url = (
|
||||
f'/api/resources/{self.id}/download' if self.file else ''
|
||||
)
|
||||
preview_url = (
|
||||
f'/api/resources/{self.id}/download?inline=1' if self.file else ''
|
||||
)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
@@ -82,6 +117,10 @@ class EncoachResource(models.Model):
|
||||
'learning_objective_names': self.learning_objective_ids.mapped('name'),
|
||||
'url': self.url or '',
|
||||
'has_file': bool(self.file),
|
||||
'mimetype': self.mimetype or '',
|
||||
'original_filename': self.original_filename or '',
|
||||
'download_url': download_url,
|
||||
'preview_url': preview_url,
|
||||
'difficulty': self.difficulty or '',
|
||||
'duration_minutes': self.duration_minutes,
|
||||
'author_id': creator.id if creator else None,
|
||||
@@ -98,5 +137,5 @@ class EncoachResource(models.Model):
|
||||
'approved': self.approved,
|
||||
'course_count': self.course_count,
|
||||
'created_at': self.create_date.isoformat() if self.create_date else '',
|
||||
'file_name': self.name,
|
||||
'file_name': self.original_filename or self.name,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user