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

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

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

View File

@@ -18,6 +18,10 @@
"depends": ["base", "encoach_core", "encoach_api"], "depends": ["base", "encoach_core", "encoach_api"],
"external_dependencies": { "external_dependencies": {
"python": ["openai", "boto3", "langgraph", "langchain_core"], "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": [ "data": [
"security/ir.model.access.csv", "security/ir.model.access.csv",

View File

@@ -4,3 +4,4 @@ from . import media_controller
from . import prompt_controller from . import prompt_controller
from . import feedback_controller from . import feedback_controller
from . import agents_controller from . import agents_controller
from . import ai_settings_controller

View File

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

View File

@@ -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 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 . import agent_tools # registry of tool handlers used by AgentRuntime
from .agent_runtime import AgentRuntime # LangGraph-backed core agent runtime 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

View File

@@ -78,6 +78,7 @@ class AgentState(TypedDict, total=False):
retrieval: list[dict] # hits from the retrieval node (RAG) retrieval: list[dict] # hits from the retrieval node (RAG)
iterations: int # guard against runaway ReAct loops iterations: int # guard against runaway ReAct loops
error: str # populated on fatal failure 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 # Factories
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def __init__(self, env, agent, *, language: str | None = None): 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.env = env
self.agent = agent self.agent = agent
self.language = language self.language = language
@@ -323,7 +331,21 @@ class AgentRuntime:
return {**state, "messages": messages, "retrieval": items} return {**state, "messages": messages, "retrieval": items}
def _node_review(self, state: AgentState) -> AgentState: 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 "" text = state.get("output_raw") or ""
if isinstance(state.get("output"), dict): if isinstance(state.get("output"), dict):
# Flatten the dict to text so the quality tools see something # Flatten the dict to text so the quality tools see something
@@ -348,29 +370,38 @@ class AgentRuntime:
}) })
if res.get("ok") is False: if res.get("ok") is False:
issues.extend(res.get("issues") or [res.get("error") or key]) 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: def _route_after_review(self, state: AgentState) -> str:
issues = state.get("quality_issues") or [] # Pure router: only inspect state, never mutate. The decision
if not issues: # was prepared in ``_node_review``.
if state.get("error"):
return "done" return "done"
if (state.get("revisions_used") or 0) >= max(0, self.agent.max_revisions): return "revise" if state.get("should_revise") else "done"
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"
# ReAct / tool-calling ------------------------------------------------- # ReAct / tool-calling -------------------------------------------------
def _node_llm_tools(self, state: AgentState) -> AgentState: def _node_llm_tools(self, state: AgentState) -> AgentState:

View 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()

View 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,
}

View File

@@ -64,7 +64,18 @@ class OpenAIService:
import os import os
api_key = os.environ.get("OPENAI_API_KEY", "") api_key = os.environ.get("OPENAI_API_KEY", "")
if _openai_mod and 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: else:
self.client = None self.client = None
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o") self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
@@ -114,6 +125,16 @@ class OpenAIService:
return messages return messages
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None): 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: try:
self.env["encoach.ai.log"].sudo().create({ self.env["encoach.ai.log"].sudo().create({
"service": "openai", "service": "openai",
@@ -128,15 +149,41 @@ class OpenAIService:
"input_preview": (inp or "")[:500], "input_preview": (inp or "")[:500],
"output_preview": (out or "")[:500], "output_preview": (out or "")[:500],
}) })
except Exception: except Exception as exc:
_logger.warning("Failed to log AI call", exc_info=True) # 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): def _check_enabled(self):
if not self.enabled: if not self.enabled:
raise RuntimeError("AI is disabled — enable in Settings > AI Configuration") 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): 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 last_exc = None
for attempt in range(self.max_retries): for attempt in range(self.max_retries):
try: try:
@@ -144,6 +191,12 @@ class OpenAIService:
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
err_str = str(exc).lower() 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_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 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: if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1:

View 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')

View File

@@ -7,12 +7,14 @@ decorator and returns JSON.
""" """
import base64 import base64
import json
import logging import logging
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
from odoo.addons.encoach_api.controllers.base import ( from odoo.addons.encoach_api.controllers.base import (
jwt_required, jwt_required,
validate_token,
_json_response, _json_response,
_error_response, _error_response,
_get_json_body, _get_json_body,
@@ -42,7 +44,65 @@ def _request_language():
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en' 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): 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 # POST /api/ai/course-plan
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -54,15 +114,21 @@ class CoursePlanController(http.Controller):
body = _get_json_body() body = _get_json_body()
if not (body.get('title') or '').strip(): if not (body.get('title') or '').strip():
return _error_response('title is required', 400) 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( pipeline = CoursePlanPipeline(
request.env, language=_request_language(), request.env, language=_request_language(),
) )
plan = pipeline.generate_plan(body) 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)}) return _json_response({'data': plan.to_api_dict(include_weeks=True)})
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.generate failed') _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 # GET /api/ai/course-plan
@@ -73,10 +139,12 @@ class CoursePlanController(http.Controller):
def list_plans(self, **kw): def list_plans(self, **kw):
try: try:
params = request.httprequest.args params = request.httprequest.args
domain = [] domain = self._plan_domain([])
search = (params.get('search') or '').strip() search = (params.get('search') or '').strip()
if search: if search:
domain.append(('name', 'ilike', 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() Plan = request.env['encoach.course.plan'].sudo()
offset, limit, page = _paginate({ offset, limit, page = _paginate({
@@ -94,7 +162,7 @@ class CoursePlanController(http.Controller):
}) })
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.list failed') _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> # GET /api/ai/course-plan/<id>
@@ -104,15 +172,15 @@ class CoursePlanController(http.Controller):
@jwt_required @jwt_required
def get_plan(self, plan_id, **kw): def get_plan(self, plan_id, **kw):
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response({ return _json_response({
'data': plan.to_api_dict(include_weeks=True, include_materials=True), '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: except Exception as exc:
_logger.exception('course-plan.get failed') _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> # DELETE /api/ai/course-plan/<id>
@@ -122,14 +190,14 @@ class CoursePlanController(http.Controller):
@jwt_required @jwt_required
def delete_plan(self, plan_id, **kw): def delete_plan(self, plan_id, **kw):
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
plan.unlink() plan.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.delete failed') _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 # POST /api/ai/course-plan/<id>/weeks/<n>/materials
@@ -139,6 +207,7 @@ class CoursePlanController(http.Controller):
@jwt_required @jwt_required
def generate_week_materials(self, plan_id, week_number, **kw): def generate_week_materials(self, plan_id, week_number, **kw):
try: try:
self._get_plan_scoped(plan_id)
pipeline = CoursePlanPipeline( pipeline = CoursePlanPipeline(
request.env, language=_request_language(), request.env, language=_request_language(),
) )
@@ -151,7 +220,7 @@ class CoursePlanController(http.Controller):
return _error_response(str(exc), 404) return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.generate_week_materials failed') _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 # GET /api/ai/course-plan/<id>/weeks/<n>/materials
@@ -161,6 +230,7 @@ class CoursePlanController(http.Controller):
@jwt_required @jwt_required
def list_week_materials(self, plan_id, week_number, **kw): def list_week_materials(self, plan_id, week_number, **kw):
try: try:
self._get_plan_scoped(plan_id)
week = request.env['encoach.course.plan.week'].sudo().search([ week = request.env['encoach.course.plan.week'].sudo().search([
('plan_id', '=', int(plan_id)), ('plan_id', '=', int(plan_id)),
('week_number', '=', int(week_number)), ('week_number', '=', int(week_number)),
@@ -173,7 +243,7 @@ class CoursePlanController(http.Controller):
}) })
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.list_week_materials failed') _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) # PHASE A — Reference sources (RAG grounding)
@@ -184,25 +254,23 @@ class CoursePlanController(http.Controller):
@jwt_required @jwt_required
def list_sources(self, plan_id, **kw): def list_sources(self, plan_id, **kw):
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response({ return _json_response({
'items': [s.to_api_dict() for s in plan.source_ids], 'items': [s.to_api_dict() for s in plan.source_ids],
'count': len(plan.source_ids), 'count': len(plan.source_ids),
}) })
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.list_sources failed') _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', @http.route('/api/ai/course-plan/<int:plan_id>/sources',
type='http', auth='none', methods=['POST'], csrf=False) type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def create_source(self, plan_id, **kw): def create_source(self, plan_id, **kw):
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
ct = request.httprequest.content_type or '' ct = request.httprequest.content_type or ''
files = request.httprequest.files files = request.httprequest.files
@@ -250,37 +318,110 @@ class CoursePlanController(http.Controller):
rec = request.env['encoach.course.plan.source'].sudo().create(vals) rec = request.env['encoach.course.plan.source'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()}) return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.create_source failed') _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', @http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
type='http', auth='none', methods=['POST'], csrf=False) type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def reindex_source(self, plan_id, source_id, **kw): def reindex_source(self, plan_id, source_id, **kw):
try: try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_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): if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Source not found', 404) return _error_response('Source not found', 404)
rec.action_index() rec.action_index()
return _json_response({'data': rec.to_api_dict()}) return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.reindex_source failed') _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>', @http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
type='http', auth='none', methods=['DELETE'], csrf=False) type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
def delete_source(self, plan_id, source_id, **kw): def delete_source(self, plan_id, source_id, **kw):
try: try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_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): if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Source not found', 404) return _error_response('Source not found', 404)
rec.unlink() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.delete_source failed') _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 # PHASE B — Deliverables preview / progress
@@ -291,13 +432,13 @@ class CoursePlanController(http.Controller):
@jwt_required @jwt_required
def get_deliverables(self, plan_id, **kw): def get_deliverables(self, plan_id, **kw):
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response(compute_deliverables(plan)) return _json_response(compute_deliverables(plan))
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.deliverables failed') _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 # 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)) rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not rec.exists(): if not rec.exists():
return None return None
self._assert_material_access(rec)
return 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', @http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
type='http', auth='none', methods=['POST'], csrf=False) type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required @jwt_required
@@ -324,12 +505,13 @@ class CoursePlanController(http.Controller):
voice=body.get('voice'), voice=body.get('voice'),
language=body.get('language') or 'en-GB', language=body.get('language') or 'en-GB',
gender=body.get('gender') or 'female', 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()}) return _json_response({'data': media.to_api_dict()})
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.gen_audio failed') _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', @http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
type='http', auth='none', methods=['POST'], csrf=False) type='http', auth='none', methods=['POST'], csrf=False)
@@ -351,7 +533,8 @@ class CoursePlanController(http.Controller):
return _json_response({'data': media.to_api_dict()}) return _json_response({'data': media.to_api_dict()})
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.gen_image failed') _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', @http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
type='http', auth='none', methods=['POST'], csrf=False) type='http', auth='none', methods=['POST'], csrf=False)
@@ -366,7 +549,8 @@ class CoursePlanController(http.Controller):
return _json_response({'data': media.to_api_dict()}) return _json_response({'data': media.to_api_dict()})
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.gen_video failed') _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', @http.route('/api/ai/course-plan/material/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False) type='http', auth='none', methods=['GET'], csrf=False)
@@ -382,7 +566,63 @@ class CoursePlanController(http.Controller):
}) })
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.list_material_media failed') _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>', @http.route('/api/ai/course-plan/media/<int:media_id>',
type='http', auth='none', methods=['DELETE'], csrf=False) 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)) rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
if not rec.exists(): if not rec.exists():
return _error_response('Media not found', 404) return _error_response('Media not found', 404)
self._assert_plan_access(rec.plan_id)
if rec.attachment_id: if rec.attachment_id:
rec.attachment_id.unlink() rec.attachment_id.unlink()
rec.unlink() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.delete_media failed') _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', @http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
type='http', auth='none', methods=['POST'], csrf=False) 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. depends on ffmpeg + the audio + image steps and is slower.
""" """
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
week = plan.week_ids.filtered( week = plan.week_ids.filtered(
lambda w: w.week_number == int(week_number), lambda w: w.week_number == int(week_number),
) )
@@ -443,7 +683,8 @@ class CoursePlanController(http.Controller):
return _json_response({'items': results, 'count': len(results)}) return _json_response({'items': results, 'count': len(results)})
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.gen_week_media failed') _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 # PHASE D — Plan assignments
@@ -454,25 +695,23 @@ class CoursePlanController(http.Controller):
@jwt_required @jwt_required
def list_assignments(self, plan_id, **kw): def list_assignments(self, plan_id, **kw):
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response({ return _json_response({
'items': [a.to_api_dict() for a in plan.assignment_ids], 'items': [a.to_api_dict() for a in plan.assignment_ids],
'count': len(plan.assignment_ids), 'count': len(plan.assignment_ids),
}) })
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.list_assignments failed') _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', @http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['POST'], csrf=False) type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def create_assignment(self, plan_id, **kw): def create_assignment(self, plan_id, **kw):
try: try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) plan = self._get_plan_scoped(plan_id)
if not plan.exists():
return _error_response('Plan not found', 404)
body = _get_json_body() or {} body = _get_json_body() or {}
mode = (body.get('mode') or 'batch').strip() mode = (body.get('mode') or 'batch').strip()
vals = { vals = {
@@ -485,25 +724,40 @@ class CoursePlanController(http.Controller):
if mode == 'batch': if mode == 'batch':
if not body.get('batch_id'): if not body.get('batch_id'):
return _error_response('batch_id is required', 400) 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': elif mode == 'students':
ids = body.get('student_user_ids') or [] ids = body.get('student_user_ids') or []
if not isinstance(ids, list) or not ids: if not isinstance(ids, list) or not ids:
return _error_response('student_user_ids is required', 400) return _error_response('student_user_ids is required', 400)
vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])] 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: else:
return _error_response('Invalid mode', 400) return _error_response('Invalid mode', 400)
rec = request.env['encoach.course.plan.assignment'].sudo().create(vals) rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()}) return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.create_assignment failed') _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>', @http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
type='http', auth='none', methods=['DELETE'], csrf=False) type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
def delete_assignment(self, plan_id, assignment_id, **kw): def delete_assignment(self, plan_id, assignment_id, **kw):
try: try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.assignment'].sudo().browse( rec = request.env['encoach.course.plan.assignment'].sudo().browse(
int(assignment_id), int(assignment_id),
) )
@@ -511,9 +765,11 @@ class CoursePlanController(http.Controller):
return _error_response('Assignment not found', 404) return _error_response('Assignment not found', 404)
rec.unlink() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc: except Exception as exc:
_logger.exception('course-plan.delete_assignment failed') _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 # PHASE E — Student-side endpoints
@@ -535,12 +791,7 @@ class CoursePlanController(http.Controller):
try: try:
user = request.env.user user = request.env.user
Assignment = request.env['encoach.course.plan.assignment'].sudo() Assignment = request.env['encoach.course.plan.assignment'].sudo()
assignments = Assignment.search([ assignments = Assignment.search([('state', '=', 'active')])
('state', '=', 'active'),
'|',
('student_user_ids', 'in', [user.id]),
'&', ('mode', '=', 'batch'), ('batch_id', '!=', False),
])
visible = [] visible = []
for a in assignments: for a in assignments:
if a.mode == 'students' and user.id in a.student_user_ids.ids: if a.mode == 'students' and user.id in a.student_user_ids.ids:
@@ -548,6 +799,9 @@ class CoursePlanController(http.Controller):
continue continue
if a.mode == 'batch' and user.id in a.expand_user_ids(): if a.mode == 'batch' and user.id in a.expand_user_ids():
visible.append(a) visible.append(a)
continue
if a.mode == 'entities' and user.id in a.expand_user_ids():
visible.append(a)
seen = set() seen = set()
out = [] out = []
@@ -582,6 +836,9 @@ class CoursePlanController(http.Controller):
if a.mode == 'batch' and user.id in a.expand_user_ids(): if a.mode == 'batch' and user.id in a.expand_user_ids():
allowed = True allowed = True
break break
if a.mode == 'entities' and user.id in a.expand_user_ids():
allowed = True
break
if not allowed: if not allowed:
return _error_response('Plan not assigned to you', 403) return _error_response('Plan not assigned to you', 403)
return _json_response({ return _json_response({

View File

@@ -54,6 +54,13 @@ class CoursePlan(models.Model):
_order = 'create_date desc, id desc' _order = 'create_date desc, id desc'
name = fields.Char(required=True) 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') course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
cefr_level = fields.Selection([ cefr_level = fields.Selection([
('pre_a1', 'Pre-A1'), ('pre_a1', 'Pre-A1'),
@@ -142,6 +149,15 @@ class CoursePlan(models.Model):
rec.media_count = len(rec.media_ids) rec.media_count = len(rec.media_ids)
rec.assignment_count = len(rec.assignment_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 # Serialisation helpers — used by the REST controller so payload
# shape stays in a single, obvious place. # shape stays in a single, obvious place.
@@ -161,6 +177,8 @@ class CoursePlan(models.Model):
data = { data = {
'id': self.id, 'id': self.id,
'name': self.name, '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_id': self.course_id.id if self.course_id else None,
'course_name': self.course_id.name if self.course_id else '', 'course_name': self.course_id.name if self.course_id else '',
'cefr_level': self.cefr_level or '', 'cefr_level': self.cefr_level or '',
@@ -264,6 +282,13 @@ class CoursePlanMaterial(models.Model):
MATERIAL_TYPE_SELECTION, required=True, default='other', MATERIAL_TYPE_SELECTION, required=True, default='other',
) )
title = fields.Char(required=True) 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( summary = fields.Text(
help='Short blurb — purpose / learning outcomes targeted / how to use.', help='Short blurb — purpose / learning outcomes targeted / how to use.',
) )
@@ -300,6 +325,8 @@ class CoursePlanMaterial(models.Model):
'skill': self.skill or '', 'skill': self.skill or '',
'material_type': self.material_type or 'other', 'material_type': self.material_type or 'other',
'title': self.title or '', '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 '', 'summary': self.summary or '',
'body': self._loads(self.body_json, {}), 'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '', 'body_text': self.body_text or '',

View File

@@ -22,6 +22,7 @@ _logger = logging.getLogger(__name__)
ASSIGNMENT_MODE_SELECTION = [ ASSIGNMENT_MODE_SELECTION = [
('batch', 'Class / Batch'), ('batch', 'Class / Batch'),
('students', 'Specific students'), ('students', 'Specific students'),
('entities', 'Entities'),
] ]
ASSIGNMENT_STATE_SELECTION = [ ASSIGNMENT_STATE_SELECTION = [
@@ -47,6 +48,10 @@ class CoursePlanAssignment(models.Model):
'res.users', 'course_plan_assignment_student_rel', 'res.users', 'course_plan_assignment_student_rel',
'assignment_id', 'user_id', string='Specific students', '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( assigned_by_id = fields.Many2one(
'res.users', default=lambda self: self.env.user, string='Assigned by', '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) 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): def _compute_student_count(self):
Enroll = self.env['op.student.course'].sudo() Enroll = self.env['op.student.course'].sudo()
Batch = self.env['op.batch'].sudo() Batch = self.env['op.batch'].sudo()
@@ -65,6 +70,14 @@ class CoursePlanAssignment(models.Model):
if rec.mode == 'students': if rec.mode == 'students':
rec.student_count = len(rec.student_user_ids) rec.student_count = len(rec.student_user_ids)
continue 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: if not rec.batch_id:
rec.student_count = 0 rec.student_count = 0
continue continue
@@ -85,6 +98,13 @@ class CoursePlanAssignment(models.Model):
self.ensure_one() self.ensure_one()
if self.mode == 'students': if self.mode == 'students':
return self.student_user_ids.ids 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: if self.mode == 'batch' and self.batch_id:
try: try:
Enroll = self.env['op.student.course'].sudo() 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 '', 'batch_name': self.batch_id.name if self.batch_id else '',
'student_user_ids': self.student_user_ids.ids, 'student_user_ids': self.student_user_ids.ids,
'student_user_names': [u.name for u in self.student_user_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, 'student_count': self.student_count or 0,
'assigned_by_id': self.assigned_by_id.id if self.assigned_by_id else None, '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 '', 'assigned_by_name': self.assigned_by_id.name if self.assigned_by_id else '',

View File

@@ -26,12 +26,25 @@ MEDIA_KIND_SELECTION = [
] ]
MEDIA_PROVIDER_SELECTION = [ MEDIA_PROVIDER_SELECTION = [
# ── Paid providers ──
('polly', 'AWS Polly'), ('polly', 'AWS Polly'),
('elevenlabs', 'ElevenLabs'), ('elevenlabs', 'ElevenLabs'),
('openai_image', 'OpenAI (DALL-E)'), ('openai_image', 'OpenAI (DALL-E)'),
('ffmpeg', 'ffmpeg (slideshow)'),
('elai', 'Elai.io'), ('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'), ('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 = [ MEDIA_STATUS_SELECTION = [
@@ -76,8 +89,16 @@ class CoursePlanMedia(models.Model):
width = fields.Integer() width = fields.Integer()
height = fields.Integer() height = fields.Integer()
download_url = fields.Char( download_url = fields.Char(
compute='_compute_download_url', store=False, compute='_compute_media_urls', store=False,
help='Web-accessible URL served by Odoo (/web/content/<id>).', 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') status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued')
@@ -89,13 +110,24 @@ class CoursePlanMedia(models.Model):
) )
@api.depends('attachment_id') @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: for rec in self:
rec.download_url = ( if not rec.id:
f'/web/content/{rec.attachment_id.id}?download=true&filename=' rec.download_url = ''
f'{rec.attachment_id.name or "media"}' rec.preview_url = ''
if rec.attachment_id else '' 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): def to_api_dict(self):
self.ensure_one() self.ensure_one()
@@ -117,6 +149,7 @@ class CoursePlanMedia(models.Model):
'height': self.height or 0, 'height': self.height or 0,
'attachment_id': self.attachment_id.id if self.attachment_id else None, 'attachment_id': self.attachment_id.id if self.attachment_id else None,
'download_url': self.download_url, 'download_url': self.download_url,
'preview_url': self.preview_url,
'status': self.status or 'queued', 'status': self.status or 'queued',
'error': self.error or '', 'error': self.error or '',
'cost_cents': self.cost_cents or 0, 'cost_cents': self.cost_cents or 0,

View File

@@ -25,6 +25,13 @@ SOURCE_KIND_SELECTION = [
('file', 'File'), ('file', 'File'),
('url', 'URL'), ('url', 'URL'),
('text', 'Inline text'), ('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 = [ SOURCE_STATUS_SELECTION = [
@@ -56,6 +63,20 @@ class CoursePlanSource(models.Model):
url = fields.Char(string='Source URL') url = fields.Char(string='Source URL')
inline_text = fields.Text(string='Inline text') 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( auto_index = fields.Boolean(
default=True, default=True,
help='If true, indexing runs automatically on create. ' help='If true, indexing runs automatically on create. '
@@ -74,18 +95,44 @@ class CoursePlanSource(models.Model):
if rec.auto_index: if rec.auto_index:
try: try:
rec.action_index() 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) _logger.exception('Auto-index failed for source %s', rec.id)
try:
rec.write({
'status': 'failed',
'error': str(exc)[:500],
})
except Exception:
pass
return records return records
def action_index(self): 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 ( from odoo.addons.encoach_ai_course.services.source_indexer import (
SourceIndexer, SourceIndexer,
) )
indexer = SourceIndexer(self.env)
for rec in self: for rec in self:
indexer = SourceIndexer(self.env) try:
indexer.index(rec) 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 return True
def unlink(self): def unlink(self):
@@ -111,6 +158,11 @@ class CoursePlanSource(models.Model):
'mime_type': self.mime_type or '', 'mime_type': self.mime_type or '',
'url': self.url or '', 'url': self.url or '',
'has_inline_text': bool(self.inline_text), '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', 'status': self.status or 'pending',
'error': self.error or '', 'error': self.error or '',
'chunks_count': self.chunks_count or 0, 'chunks_count': self.chunks_count or 0,

View File

@@ -29,6 +29,21 @@ try:
except ImportError: except ImportError:
OpenAIService = None 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 # AgentRuntime is the LangGraph-backed engine. When the feature flag
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with # ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
# the matching key is configured, the pipeline routes through the agent # 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__) _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 # 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 # string (rather than an OpenAI function call) makes it portable if the
# underlying `chat_json` implementation ever changes providers. # underlying `chat_json` implementation ever changes providers.
@@ -270,10 +299,46 @@ class CoursePlanPipeline:
max_tokens=4096, max_tokens=4096,
action="course_plan.generate", 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: if content is None or 'error' in content:
raise RuntimeError( ai_error = (content or {}).get('error', 'AI generation failed.')
(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 = { plan_vals = {
'name': title, 'name': title,
@@ -283,14 +348,14 @@ class CoursePlanPipeline:
'total_weeks': total_weeks, 'total_weeks': total_weeks,
'contact_hours_per_week': contact_hours, 'contact_hours_per_week': contact_hours,
'skills_division': skills_division, 'skills_division': skills_division,
'description': (content.get('description') or '').strip(), 'description': description,
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False), 'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
'outcomes_json': json.dumps(content.get('outcomes') 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), 'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
'assessment_json': json.dumps(content.get('assessment') 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), 'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
'brief_json': json.dumps(brief, 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'): if brief.get('course_id'):
try: try:
@@ -370,10 +435,23 @@ class CoursePlanPipeline:
max_tokens=6000, max_tokens=6000,
action="course_plan.generate_week", action="course_plan.generate_week",
) )
used_week_fallback = False
if content is None or 'error' in content: if content is None or 'error' in content:
raise RuntimeError( ai_error = (content or {}).get('error', 'AI generation failed.')
(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 # Wipe any previous materials for this week so re-generating is
# idempotent and we never accumulate duplicates. # idempotent and we never accumulate duplicates.
@@ -381,19 +459,28 @@ class CoursePlanPipeline:
('plan_id', '=', plan.id), ('week_id', '=', week.id), ('plan_id', '=', plan.id), ('week_id', '=', week.id),
]) ])
if existing: 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() Material = self.env['encoach.course.plan.material'].sudo()
created = [] 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 []: for m in content.get('materials') or []:
try: try:
summary = (m.get('summary') or '').strip()
if used_week_fallback:
summary = (summary + "\n\n" + skeleton_note).strip()
rec = Material.create({ rec = Material.create({
'plan_id': plan.id, 'plan_id': plan.id,
'week_id': week.id, 'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(), 'skill': (m.get('skill') or 'integrated').strip().lower(),
'material_type': (m.get('material_type') or 'other').strip(), 'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled', '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_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}), 'body_text': self._flatten_body(m.get('body') or {}),
}) })
@@ -440,10 +527,21 @@ class CoursePlanPipeline:
payload=user_msg, payload=user_msg,
extra_system=system_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( _logger.warning(
"agent %s failed (%s); falling back to direct chat_json", "agent %s failed (%s); falling back to direct chat_json",
agent_key, final.get("error"), agent_key, agent_error,
) )
else: else:
output = final.get("output") output = final.get("output")
@@ -465,6 +563,186 @@ class CoursePlanPipeline:
action=action, 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 @staticmethod
def _flatten_body(body): def _flatten_body(body):
"""Produce a plain-text dump of a material body for quick preview. """Produce a plain-text dump of a material body for quick preview.

View File

@@ -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 with the bytes attached as an ``ir.attachment`` so the existing
``/web/content/<id>`` URL serving works without extra plumbing. ``/web/content/<id>`` URL serving works without extra plumbing.
Audio: PROVIDER FALLBACK CHAIN (Phase 24.1, Apr 2026)
Synthesise a TTS narration of a listening script or speaking ==============================================
model-answer using AWS Polly (preferred) with a fallback to Every modality now tries providers in order: the explicitly-requested or
ElevenLabs when configured. The voice picks itself from the plan's admin-configured paid provider first, then a sequence of *free* fallbacks.
target CEFR + a ``voice_key`` param. 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: * Image: ``openai (DALL-E 3) → pillow (offline placeholder) → unsplash``.
Use OpenAI's DALL-E 3 (via ``OpenAIService.generate_image``) with a * Audio: ``polly | elevenlabs → gtts → silent-stub``.
structured prompt built from the material body. Per-plan image * Video: ``ffmpeg (image+audio) → static (text-only image card)``.
budgets are enforced so a single bad call doesn't bill an admin's
OpenAI account dry.
Video: The active provider per capability is read fresh from
Combine a generated image (or, if missing, generate one first) ``ir.config_parameter`` on every call (see
with the audio narration into an MP4 using a local ``ffmpeg`` :mod:`encoach_ai.services.provider_router`) so toggling a provider in
subprocess. No third-party rendering service required for the the admin UI takes effect on the very next request without restarting.
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.
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import io
import logging import logging
import mimetypes
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -42,6 +37,12 @@ from typing import Optional
_logger = logging.getLogger(__name__) _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 ---------------------------------------------------------------- # --- 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): def _get_param(env, key, default):
return env['ir.config_parameter'].sudo().get_param(key, default) return env['ir.config_parameter'].sudo().get_param(key, default)
def _enforce_image_budget(env, plan, planned_images: int = 1) -> None: 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')) cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60'))
if cap <= 0: if cap <= 0:
return return
@@ -82,12 +81,14 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
used = Media.search_count([ used = Media.search_count([
('plan_id', '=', plan.id), ('plan_id', '=', plan.id),
('kind', '=', 'image'), ('kind', '=', 'image'),
('provider', '=', 'openai_image'),
('status', 'in', ('ready', 'generating')), ('status', 'in', ('ready', 'generating')),
]) ])
if used + planned_images > cap: if used + planned_images > cap:
raise RuntimeError( raise RuntimeError(
f'Image budget exceeded for this plan: {used} used, cap is {cap}. ' f'Paid image budget exceeded for this plan: {used} used, cap is '
f'Raise encoach_ai_course.image_budget_per_plan or delete old images.' 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}.' f'Scene: {snippet} Style: {style_hint}.'
) )
if material.material_type == 'vocabulary_list': if material.material_type == 'vocabulary_list':
# Caller should pass a single term explicitly via ``custom_prompt``.
words = body.get('words') or [] words = body.get('words') or []
if words: if words:
term = words[0].get('term') or material.title 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 --------------------------------------------------------- # --- Public service ---------------------------------------------------------
class MediaService: 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): def __init__(self, env):
self.env = env self.env = env
@@ -157,8 +177,19 @@ class MediaService:
def synthesize_audio(self, material, *, voice: Optional[str] = None, def synthesize_audio(self, material, *, voice: Optional[str] = None,
language: str = 'en-GB', language: str = 'en-GB',
gender: str = 'female', gender: str = 'female',
provider: str = 'polly') -> 'models.Model': provider: str = 'auto') -> 'models.Model':
"""Generate a narration MP3 for ``material`` and persist it.""" """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 = self.env['encoach.course.plan.media'].sudo()
media = Media.create({ media = Media.create({
'plan_id': material.plan_id.id, 'plan_id': material.plan_id.id,
@@ -176,117 +207,252 @@ class MediaService:
media.write({'status': 'failed', 'error': 'No script text to narrate'}) media.write({'status': 'failed', 'error': 'No script text to narrate'})
return media return media
media.write({'source_text': text[:3000]}) media.write({'source_text': text[:3000]})
try:
audio_bytes = self._call_tts( chain = provider_router.resolve_chain(
text, voice=voice, language=language, self.env, 'audio', requested=provider if provider != 'auto' else None,
gender=gender, provider=provider, )
) errors = []
attach = _attach_bytes( for prov in chain:
self.env, try:
name=f'plan-{material.plan_id.id}-week-{material.week_number}' result = self._call_audio_provider(
f'-{material.material_type}-{material.id}.mp3', prov, text=text, voice=voice,
mime_type='audio/mpeg', language=language, gender=gender,
data=audio_bytes, )
res_model='encoach.course.plan.media', content_type = result.get('content_type', 'audio/mpeg')
res_id=media.id, ext = 'wav' if content_type == 'audio/wav' else 'mp3'
) attach = _attach_bytes(
media.write({ self.env,
'attachment_id': attach.id, name=f'plan-{material.plan_id.id}-week-{material.week_number}'
'mime_type': 'audio/mpeg', f'-{material.material_type}-{material.id}.{ext}',
'size_bytes': len(audio_bytes), mime_type=content_type,
'status': 'ready', data=result['audio'],
'error': False, res_model='encoach.course.plan.media',
}) res_id=media.id,
except Exception as exc: )
_logger.exception('TTS failed for material %s', material.id) media.write({
media.write({'status': 'failed', 'error': str(exc)[:500]}) '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 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': if provider == 'elevenlabs':
from odoo.addons.encoach_ai.services.elevenlabs_service import ( from odoo.addons.encoach_ai.services.elevenlabs_service import (
ElevenLabsService, ElevenLabsService,
) )
svc = ElevenLabsService(self.env) res = ElevenLabsService(self.env).synthesize(
res = svc.synthesize(text, voice_id=voice or None) 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 ( return {
PollyService, 'audio': res.get('audio') or res.get('audio_bytes') or b'',
) 'content_type': res.get('content_type', 'audio/mpeg'),
svc = PollyService(self.env) 'voice': res.get('voice') or voice or 'elevenlabs',
res = svc.synthesize( 'characters': len(text),
text, voice=voice, language=language, gender=gender, }
) if provider == 'gtts':
return res['audio'] 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 ----------------------------------------------------------- # -- Image -----------------------------------------------------------
def generate_image(self, material, *, def generate_image(self, material, *,
custom_prompt: Optional[str] = None, custom_prompt: Optional[str] = None,
size: str = '1024x1024', size: str = '1024x1024',
style: str = 'natural', style: str = 'natural',
quality: str = 'standard') -> 'models.Model': quality: str = 'standard',
"""Generate a DALL-E 3 illustration for ``material``.""" provider: str = 'auto') -> 'models.Model':
"""Generate an illustration for ``material`` with provider fallback."""
Media = self.env['encoach.course.plan.media'].sudo() Media = self.env['encoach.course.plan.media'].sudo()
plan = material.plan_id plan = material.plan_id
_enforce_image_budget(self.env, plan, planned_images=1)
prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip() prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip()
media = Media.create({ media = Media.create({
'plan_id': plan.id, 'plan_id': plan.id,
'week_id': material.week_id.id if material.week_id else False, 'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id, 'material_id': material.id,
'kind': 'image', 'kind': 'image',
'provider': 'openai_image', 'provider': provider,
'title': f'{material.title} — illustration', 'title': f'{material.title} — illustration',
'source_text': prompt[:3000], 'source_text': prompt[:3000],
'style': style, 'style': style,
'status': 'generating', '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 ( from odoo.addons.encoach_ai.services.openai_service import (
OpenAIService, OpenAIService,
) )
svc = OpenAIService(self.env) res = OpenAIService(self.env).generate_image(
result = svc.generate_image(
prompt, size=size, style=style, quality=quality, prompt, size=size, style=style, quality=quality,
) )
img = result['image'] return {
attach = _attach_bytes( 'image': res['image'],
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,
'mime_type': 'image/png', 'mime_type': 'image/png',
'size_bytes': len(img), }
'width': w, if provider == 'pillow':
'height': h, from odoo.addons.encoach_ai.services.free_image import (
'status': 'ready', render_placeholder,
'error': False, )
'cost_cents': 4 if quality == 'standard' else 8, png = render_placeholder(
}) material.title or 'Course material',
except Exception as exc: subtitle=_image_subtitle(material, plan=plan),
_logger.exception('Image gen failed for material %s', material.id) size=size,
media.write({'status': 'failed', 'error': str(exc)[:500]}) seed=material.id,
return media )
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 ----------------------------------------------------------- # -- Video -----------------------------------------------------------
def compose_video(self, material, *, audio_media=None, 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``. """Compose a slide-style MP4 (image + audio) for ``material``.
Auto-creates audio and/or image first if the caller didn't pass Strategy:
them and they don't already exist on the material. Requires
``ffmpeg`` on PATH; without it the media row is marked failed 1. Try ``ffmpeg`` (real slideshow video) if ffmpeg is on PATH.
with a clear error message. 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() Media = self.env['encoach.course.plan.media'].sudo()
plan = material.plan_id plan = material.plan_id
@@ -295,93 +461,160 @@ class MediaService:
'week_id': material.week_id.id if material.week_id else False, 'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id, 'material_id': material.id,
'kind': 'video', 'kind': 'video',
'provider': 'ffmpeg', 'provider': provider,
'title': f'{material.title} — slideshow', 'title': f'{material.title} — slideshow',
'status': 'generating', 'status': 'generating',
}) })
if shutil.which('ffmpeg') is None: chain = provider_router.resolve_chain(
media.write({ self.env, 'video', requested=provider if provider != 'auto' else None,
'status': 'failed', )
'error': 'ffmpeg not found on PATH; install it on the server', errors = []
}) for prov in chain:
return media try:
if prov == 'ffmpeg':
try: return self._compose_video_ffmpeg(
audio = audio_media or material.media_ids.filtered( media, material, audio_media, image_media,
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( if prov == 'static':
lambda m: m.kind == 'image' and m.status == 'ready' return self._compose_video_static(media, material)
)[:1] raise RuntimeError(f'Unknown video provider: {prov!r}')
if not image: except Exception as exc:
image = self.generate_image(material) kind = classify_provider_error(exc)
if image.status != 'ready': msg = f'[{prov}/{kind}] {str(exc)[:200]}'
raise RuntimeError( errors.append(msg)
f'Image prerequisite not ready: {image.error or "unknown"}' _logger.warning(
) 'Video provider %s failed (%s) for material %s — trying next',
prov, kind, material.id,
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 media.write({
if proc.returncode != 0: 'status': 'failed',
err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:] 'error': ('All video providers failed: '
raise RuntimeError(f'ffmpeg failed: {err}') + ' | '.join(errors))[:500],
with open(v_path, 'rb') as f: })
video_bytes = f.read() return media
attach = _attach_bytes(
self.env, # ── Concrete video providers ────────────────────────────────────────
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.mp4', def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
mime_type='video/mp4', """Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
data=video_bytes, if shutil.which('ffmpeg') is None:
res_model='encoach.course.plan.media', raise RuntimeError('ffmpeg not found on PATH')
res_id=media.id,
) plan = material.plan_id
media.write({ audio = audio_media or material.media_ids.filtered(
'attachment_id': attach.id, lambda m: m.kind == 'audio' and m.status == 'ready'
'mime_type': 'video/mp4', )[:1]
'size_bytes': len(video_bytes), if not audio:
'duration_seconds': float(audio.duration_seconds or elapsed or 0), audio = self.synthesize_audio(material)
'width': 1280, if audio.status != 'ready':
'height': 720, raise RuntimeError(
'status': 'ready', f'Audio prerequisite not ready: {audio.error or "unknown"}'
'error': False, )
}) image = image_media or material.media_ids.filtered(
except Exception as exc: lambda m: m.kind == 'image' and m.status == 'ready'
_logger.exception('Video compose failed for material %s', material.id) )[:1]
media.write({'status': 'failed', 'error': str(exc)[:500]}) 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 return media

View File

@@ -17,6 +17,7 @@ error fields. That way one bad PDF doesn't block the rest.
from __future__ import annotations from __future__ import annotations
import base64
import io import io
import logging import logging
from datetime import datetime from datetime import datetime
@@ -111,6 +112,69 @@ class SourceIndexer:
if source.kind == 'text': if source.kind == 'text':
return (source.inline_text or '').strip() 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': if source.kind == 'url':
url = (source.url or '').strip() url = (source.url or '').strip()
if not url: if not url:
@@ -133,10 +197,24 @@ class SourceIndexer:
'application/msword', 'application/msword',
) or name.endswith('.docx') or name.endswith('.doc')): ) or name.endswith('.docx') or name.endswith('.doc')):
return _extract_docx(payload) return _extract_docx(payload)
try: # Whitelist plain-text-shaped uploads explicitly. Anything else
return payload.decode('utf-8', errors='replace').strip() # (xlsx, png, mp3, zip, …) must be rejected with a clear error
except Exception as exc: # rather than silently UTF-8-decoded into garbage that we'd
raise RuntimeError(f'Cannot decode file: {exc}') from exc # 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}') raise ValueError(f'Unknown source kind: {source.kind!r}')

View File

@@ -94,12 +94,39 @@ def _get_jwt_secret():
return secret return secret
def validate_token(): def _extract_bearer_token(allow_query_param: bool = False) -> str | None:
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or 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", "") 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 return None
token = auth_header[7:]
secret = _get_jwt_secret() secret = _get_jwt_secret()
if not secret: if not secret:
_logger.error("System parameter 'encoach.jwt_secret' is not configured") _logger.error("System parameter 'encoach.jwt_secret' is not configured")

View File

@@ -304,8 +304,23 @@ class ApprovalWorkflowController(http.Controller):
if not req_rec.exists(): if not req_rec.exists():
return _error_response('Request not found', 404) 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(): with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage: if stage:
stage.write({ stage.write({
'status': 'approved', 'status': 'approved',
@@ -362,8 +377,20 @@ class ApprovalWorkflowController(http.Controller):
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id) req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists(): if not req_rec.exists():
return _error_response('Request not found', 404) 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(): with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage: if stage:
stage.write({ stage.write({
'status': 'rejected', 'status': 'rejected',

View File

@@ -11,6 +11,18 @@ _logger = logging.getLogger(__name__)
ENTITY_MODEL = 'encoach.entity' 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): def _entity_to_dict(entity):
d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else { d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else {
'id': entity.id, '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): class EntityController(http.Controller):
@http.route('/api/entities', type='http', auth='public', @http.route('/api/entities', type='http', auth='public',
@@ -59,6 +81,62 @@ class EntityController(http.Controller):
_logger.exception('list entities failed') _logger.exception('list entities failed')
return _error_response(str(e), 500) 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', @http.route('/api/entities/<int:entity_id>', type='http', auth='public',
methods=['GET'], csrf=False) methods=['GET'], csrf=False)
@jwt_required @jwt_required

View File

@@ -224,7 +224,15 @@ class AcademicController(http.Controller):
'term_end_date': str(e), 'term_end_date': str(e),
'academic_year_id': year.id, '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}) q1.write({'parent_id': parent.id})
q2.write({'parent_id': parent.id}) q2.write({'parent_id': parent.id})
quarters += [q1, q2] quarters += [q1, q2]

View File

@@ -11,6 +11,48 @@ _logger = logging.getLogger(__name__)
# Helpers # 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): def _serialize_course(c):
subj = getattr(c, 'encoach_subject_id', False) subj = getattr(c, 'encoach_subject_id', False)
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag']) 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, 'chapter_count': getattr(c, 'chapter_count', 0) or 0,
'resource_count': getattr(c, 'resource_count', 0) or 0, 'resource_count': getattr(c, 'resource_count', 0) or 0,
'objective_count': getattr(c, 'objective_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, 'batch_name': batch_name,
'partner_id': partner.id, 'partner_id': partner.id,
'user_id': s.user_id.id if s.user_id else None, '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 '', 'department_name': dept.name if dept else '',
'specialization': getattr(f, 'specialization', '') or '', 'specialization': getattr(f, 'specialization', '') or '',
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [], '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, 'max_students': getattr(b, 'max_students', 0) or 0,
'student_count': len(students), 'student_count': len(students),
'students': 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): def list_courses(self, **kw):
try: try:
Course = request.env['op.course'].sudo() Course = request.env['op.course'].sudo()
domain = [] domain = _scoped_entity_domain([])
if kw.get('status'): if kw.get('status'):
pass # op.course has no status field by default 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) offset, limit, page = _paginate(kw)
total = Course.search_count(domain) total = Course.search_count(domain)
records = Course.search(domain, offset=offset, limit=limit, order='id desc') records = Course.search(domain, offset=offset, limit=limit, order='id desc')
@@ -154,7 +206,7 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_courses failed') _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) @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
@@ -163,6 +215,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id) rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) 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)}) return _json_response({'data': _serialize_course(rec)})
except Exception as e: except Exception as e:
_logger.exception('get_course failed') _logger.exception('get_course failed')
@@ -173,10 +228,17 @@ class LmsCoreController(http.Controller):
def create_course(self, **kw): def create_course(self, **kw):
try: try:
body = _get_json_body() 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 = { vals = {
'name': body.get('name', ''), 'name': body.get('name', ''),
'code': body.get('code', ''), 'code': body.get('code', ''),
} }
if entity_id:
vals['entity_id'] = entity_id
if body.get('description'): if body.get('description'):
vals['description'] = body['description'] vals['description'] = body['description']
if body.get('max_capacity'): if body.get('max_capacity'):
@@ -197,7 +259,7 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_course(rec)}) return _json_response({'data': _serialize_course(rec)})
except Exception as e: except Exception as e:
_logger.exception('create_course failed') _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) @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required @jwt_required
@@ -206,6 +268,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id) rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) 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() body = _get_json_body()
vals = {} vals = {}
for k in ('name', 'code', 'description'): 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']])] vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
if 'tag_ids' in body: if 'tag_ids' in body:
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])] 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: if vals:
rec.write(vals) rec.write(vals)
return _json_response({'data': _serialize_course(rec)}) return _json_response({'data': _serialize_course(rec)})
except Exception as e: except Exception as e:
_logger.exception('update_course failed') _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) @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
@@ -239,6 +306,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id) rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists(): if not rec.exists():
return _json_response({'success': True}) 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') force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes')
enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)]) enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)])
if enrollments and not force: if enrollments and not force:
@@ -272,8 +342,12 @@ class LmsCoreController(http.Controller):
('student_id', '=', student.id) ('student_id', '=', student.id)
]) ])
course_ids = course_details.mapped('course_id') course_ids = course_details.mapped('course_id')
ids, is_super = _entity_scope()
items = [] items = []
for c in course_ids: 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) data = _serialize_course(c)
cd = course_details.filtered(lambda d: d.course_id.id == c.id) 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 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) student = request.env['op.student'].sudo().browse(student_id)
if not student.exists(): if not student.exists():
return _error_response('Student not found', 404) 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() body = _get_json_body()
course_id = body.get('course_id') course_id = body.get('course_id')
course_ids = body.get('course_ids', []) course_ids = body.get('course_ids', [])
@@ -317,6 +394,13 @@ class LmsCoreController(http.Controller):
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
for cid in course_ids: for cid in course_ids:
cid = int(cid) 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([ existing = SC.search([
('student_id', '=', student.id), ('student_id', '=', student.id),
('course_id', '=', cid), ('course_id', '=', cid),
@@ -347,6 +431,9 @@ class LmsCoreController(http.Controller):
course = request.env['op.course'].sudo().browse(course_id) course = request.env['op.course'].sudo().browse(course_id)
if not course.exists(): if not course.exists():
return _error_response('Course not found', 404) 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() body = _get_json_body()
student_ids = [int(sid) for sid in body.get('student_ids', [])] student_ids = [int(sid) for sid in body.get('student_ids', [])]
batch_id = body.get('batch_id') batch_id = body.get('batch_id')
@@ -358,6 +445,13 @@ class LmsCoreController(http.Controller):
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
enrolled = [] enrolled = []
for sid in student_ids: 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([ existing = SC.search([
('student_id', '=', sid), ('student_id', '=', sid),
('course_id', '=', course_id), ('course_id', '=', course_id),
@@ -385,11 +479,13 @@ class LmsCoreController(http.Controller):
def list_students(self, **kw): def list_students(self, **kw):
try: try:
Student = request.env['op.student'].sudo() Student = request.env['op.student'].sudo()
domain = [] domain = _scoped_entity_domain([])
if kw.get('search'): if kw.get('search'):
domain = [('partner_id.name', 'ilike', kw['search'])] domain.append(('partner_id.name', 'ilike', kw['search']))
if kw.get('batch_id'): if kw.get('batch_id'):
domain.append(('course_detail_ids.batch_id', '=', int(kw['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) offset, limit, page = _paginate(kw)
total = Student.search_count(domain) total = Student.search_count(domain)
records = Student.search(domain, offset=offset, limit=limit, order='id desc') records = Student.search(domain, offset=offset, limit=limit, order='id desc')
@@ -402,7 +498,7 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_students failed') _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) @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
@@ -411,6 +507,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.student'].sudo().browse(student_id) rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) 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)}) return _json_response({'data': _serialize_student(rec)})
except Exception as e: except Exception as e:
_logger.exception('get_student failed') _logger.exception('get_student failed')
@@ -421,6 +520,11 @@ class LmsCoreController(http.Controller):
def create_student(self, **kw): def create_student(self, **kw):
try: try:
body = _get_json_body() 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', '') first = body.get('first_name', '')
last = body.get('last_name', '') last = body.get('last_name', '')
name = f"{first} {last}".strip() name = f"{first} {last}".strip()
@@ -434,6 +538,8 @@ class LmsCoreController(http.Controller):
'partner_id': partner.id, 'partner_id': partner.id,
'gender': body.get('gender', ''), 'gender': body.get('gender', ''),
} }
if entity_id:
student_vals['entity_id'] = entity_id
if body.get('birth_date'): if body.get('birth_date'):
student_vals['birth_date'] = body['birth_date'] student_vals['birth_date'] = body['birth_date']
student = request.env['op.student'].sudo().create(student_vals) student = request.env['op.student'].sudo().create(student_vals)
@@ -453,11 +559,13 @@ class LmsCoreController(http.Controller):
'password': body.get('password', 'student123'), 'password': body.get('password', 'student123'),
'partner_id': partner.id, '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}) student.sudo().write({'user_id': user.id})
return _json_response({'data': _serialize_student(student)}) return _json_response({'data': _serialize_student(student)})
except Exception as e: except Exception as e:
_logger.exception('create_student failed') _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) @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required @jwt_required
@@ -466,6 +574,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.student'].sudo().browse(student_id) rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) 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() body = _get_json_body()
partner_vals = {} partner_vals = {}
if 'first_name' in body or 'last_name' in body: if 'first_name' in body or 'last_name' in body:
@@ -481,12 +592,14 @@ class LmsCoreController(http.Controller):
student_vals = {} student_vals = {}
if 'gender' in body: if 'gender' in body:
student_vals['gender'] = body['gender'] 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: if student_vals:
rec.write(student_vals) rec.write(student_vals)
return _json_response({'data': _serialize_student(rec)}) return _json_response({'data': _serialize_student(rec)})
except Exception as e: except Exception as e:
_logger.exception('update_student failed') _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) @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
@@ -494,6 +607,9 @@ class LmsCoreController(http.Controller):
try: try:
rec = request.env['op.student'].sudo().browse(student_id) rec = request.env['op.student'].sudo().browse(student_id)
if rec.exists(): 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() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as e: except Exception as e:
@@ -507,9 +623,11 @@ class LmsCoreController(http.Controller):
def list_teachers(self, **kw): def list_teachers(self, **kw):
try: try:
Faculty = request.env['op.faculty'].sudo() Faculty = request.env['op.faculty'].sudo()
domain = [] domain = _scoped_entity_domain([])
if kw.get('search'): 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) offset, limit, page = _paginate(kw)
total = Faculty.search_count(domain) total = Faculty.search_count(domain)
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc') records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
@@ -522,13 +640,18 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_teachers failed') _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) @http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def create_teacher(self, **kw): def create_teacher(self, **kw):
try: try:
body = _get_json_body() 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', '') first = body.get('first_name', '')
last = body.get('last_name', '') last = body.get('last_name', '')
name = f"{first} {last}".strip() name = f"{first} {last}".strip()
@@ -541,6 +664,8 @@ class LmsCoreController(http.Controller):
'partner_id': partner.id, 'partner_id': partner.id,
'gender': body.get('gender', ''), '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'): if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
fac_vals['department_id'] = int(body['department_id']) fac_vals['department_id'] = int(body['department_id'])
if body.get('birth_date'): if body.get('birth_date'):
@@ -549,14 +674,64 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_teacher(faculty)}) return _json_response({'data': _serialize_teacher(faculty)})
except Exception as e: except Exception as e:
_logger.exception('create_teacher failed') _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) 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) @http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
def delete_teacher(self, teacher_id, **kw): def delete_teacher(self, teacher_id, **kw):
try: try:
rec = request.env['op.faculty'].sudo().browse(teacher_id) rec = request.env['op.faculty'].sudo().browse(teacher_id)
if rec.exists(): 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() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as e: except Exception as e:
@@ -570,7 +745,9 @@ class LmsCoreController(http.Controller):
def list_batches(self, **kw): def list_batches(self, **kw):
try: try:
Batch = request.env['op.batch'].sudo() 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) offset, limit, page = _paginate(kw)
total = Batch.search_count(domain) total = Batch.search_count(domain)
records = Batch.search(domain, offset=offset, limit=limit, order='id desc') records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
@@ -583,7 +760,7 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_batches failed') _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) @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
@@ -592,6 +769,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.batch'].sudo().browse(batch_id) rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) 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)}) return _json_response({'data': _serialize_batch(rec)})
except Exception as e: except Exception as e:
_logger.exception('get_batch failed') _logger.exception('get_batch failed')
@@ -602,11 +782,18 @@ class LmsCoreController(http.Controller):
def create_batch(self, **kw): def create_batch(self, **kw):
try: try:
body = _get_json_body() 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', '')} vals = {'name': body.get('name', '')}
if body.get('code'): if body.get('code'):
vals['code'] = body['code'] vals['code'] = body['code']
if body.get('course_id'): if body.get('course_id'):
vals['course_id'] = int(body['course_id']) vals['course_id'] = int(body['course_id'])
if entity_id:
vals['entity_id'] = entity_id
if body.get('start_date'): if body.get('start_date'):
vals['start_date'] = body['start_date'] vals['start_date'] = body['start_date']
if body.get('end_date'): if body.get('end_date'):
@@ -615,7 +802,7 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_batch(rec)}) return _json_response({'data': _serialize_batch(rec)})
except Exception as e: except Exception as e:
_logger.exception('create_batch failed') _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) @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required @jwt_required
@@ -624,6 +811,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.batch'].sudo().browse(batch_id) rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) 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() body = _get_json_body()
vals = {} vals = {}
for k in ('name', 'code', 'start_date', 'end_date'): for k in ('name', 'code', 'start_date', 'end_date'):
@@ -631,12 +821,14 @@ class LmsCoreController(http.Controller):
vals[k] = body[k] vals[k] = body[k]
if 'course_id' in body: if 'course_id' in body:
vals['course_id'] = int(body['course_id']) 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: if vals:
rec.write(vals) rec.write(vals)
return _json_response({'data': _serialize_batch(rec)}) return _json_response({'data': _serialize_batch(rec)})
except Exception as e: except Exception as e:
_logger.exception('update_batch failed') _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) @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
@@ -644,6 +836,9 @@ class LmsCoreController(http.Controller):
try: try:
rec = request.env['op.batch'].sudo().browse(batch_id) rec = request.env['op.batch'].sudo().browse(batch_id)
if rec.exists(): 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() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as e: except Exception as e:
@@ -655,6 +850,12 @@ class LmsCoreController(http.Controller):
def list_batch_students(self, batch_id, **kw): def list_batch_students(self, batch_id, **kw):
try: try:
SC = request.env['op.student.course'].sudo() 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)]) recs = SC.search([('batch_id', '=', batch_id)])
students = [] students = []
for sc in recs: for sc in recs:
@@ -680,11 +881,19 @@ class LmsCoreController(http.Controller):
batch = request.env['op.batch'].sudo().browse(batch_id) batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists(): if not batch.exists():
return _error_response('Batch not found', 404) 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() body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])] student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
added = [] added = []
for sid in student_ids: 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([ existing = SC.search([
('student_id', '=', sid), ('student_id', '=', sid),
('batch_id', '=', batch_id), ('batch_id', '=', batch_id),
@@ -719,6 +928,12 @@ class LmsCoreController(http.Controller):
def remove_students_from_batch(self, batch_id, **kw): def remove_students_from_batch(self, batch_id, **kw):
"""Remove students from a batch by clearing their batch_id.""" """Remove students from a batch by clearing their batch_id."""
try: 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() body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])] student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
@@ -751,6 +966,9 @@ class LmsCoreController(http.Controller):
course = request.env['op.course'].sudo().browse(course_id) course = request.env['op.course'].sudo().browse(course_id)
if not course.exists(): if not course.exists():
return _error_response('Not found', 404) 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 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'] 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'] 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): def subject_courses(self, subject_id, **kw):
"""Return all courses linked to a given taxonomy subject.""" """Return all courses linked to a given taxonomy subject."""
try: try:
courses = request.env['op.course'].sudo().search([ courses = request.env['op.course'].sudo().search(_scoped_entity_domain([
('encoach_subject_id', '=', subject_id) ('encoach_subject_id', '=', subject_id)
]) ]))
return _json_response({ return _json_response({
'items': [_serialize_course(c) for c in courses], 'items': [_serialize_course(c) for c in courses],
'total': len(courses), 'total': len(courses),

View File

@@ -63,17 +63,70 @@ def _attempt_completed_at(att):
return None 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): 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 = [] domain = []
if reportable: if reportable:
domain.append(('status', 'in', list(REPORTABLE_STATUSES))) 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: try:
domain.append(('entity_id', '=', int(entity_id))) requested_int = int(requested_entity)
except (TypeError, ValueError): 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') user_id = kw.get('user_id') or kw.get('student_id')
if user_id: if user_id:
try: try:

View File

@@ -1,17 +1,90 @@
import base64 import base64
import logging import logging
import mimetypes
import os
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
from odoo.addons.encoach_api.controllers.base import ( from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate, jwt_required, _json_response, _error_response, _get_json_body, _paginate,
validate_token,
) )
_logger = logging.getLogger(__name__) _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): def _ser_resource(r):
tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag'] 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'] 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 { return {
'id': r.id, 'id': r.id,
'name': r.name or '', '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], 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'url': r.url or '', 'url': r.url or '',
'has_file': bool(r.file), '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 '', 'difficulty': r.difficulty or '',
'duration_minutes': r.duration_minutes or 0, 'duration_minutes': r.duration_minutes or 0,
'author_id': r.creator_id.id if r.creator_id else None, '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'): if params.get('cefr_level'):
vals['cefr_level'] = params['cefr_level'] vals['cefr_level'] = params['cefr_level']
if f: 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) rec = request.env['encoach.resource'].sudo().create(vals)
return _json_response({'data': _ser_resource(rec)}) return _json_response({'data': _ser_resource(rec)})
except Exception as e: except Exception as e:
@@ -195,21 +302,49 @@ class ResourcesController(http.Controller):
except Exception as e: except Exception as e:
return _error_response(str(e), 500) return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>/download', type='http', auth='public', methods=['GET'], csrf=False) # ``auth='none'`` + manual JWT validation lets us accept the token
@jwt_required # 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): def download_resource(self, rid, **kw):
try: 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) rec = request.env['encoach.resource'].sudo().browse(rid)
if not rec.exists() or not rec.file: if not rec.exists() or not rec.file:
return _error_response('No file', 404) 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) 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, [ return request.make_response(data, [
('Content-Type', mime), ('Content-Type', mime),
('Content-Disposition', f'attachment; filename="{rec.name}"'), ('Content-Disposition', f'{disposition_kind}; filename="{filename}"'),
('Content-Length', str(len(data))), ('Content-Length', str(len(data))),
('Cache-Control', 'private, max-age=3600'),
]) ])
except Exception as e: except Exception as e:
_logger.exception('download_resource') _logger.exception('download_resource')

View File

@@ -6,6 +6,13 @@ class OpCourseExt(models.Model):
description = fields.Text('Description') description = fields.Text('Description')
max_capacity = fields.Integer('Max Capacity', default=30) 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_id = fields.Many2one(
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True, 'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
@@ -54,3 +61,39 @@ class OpCourseExt(models.Model):
('resource_id', '!=', False), ('resource_id', '!=', False),
]) ])
rec.resource_count = len(mats.mapped('resource_id')) 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.',
)

View File

@@ -12,6 +12,13 @@ class EncoachResource(models.Model):
('document', 'Document'), ('document', 'Document'),
('link', 'Link'), ('link', 'Link'),
('interactive', 'Interactive'), ('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([ review_status = fields.Selection([
('pending', 'Pending'), ('pending', 'Pending'),
@@ -30,6 +37,24 @@ class EncoachResource(models.Model):
'resource_id', 'tag_id', string='Tags', 'resource_id', 'tag_id', string='Tags',
) )
file = fields.Binary(attachment=True) 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() url = fields.Char()
difficulty = fields.Selection([ difficulty = fields.Selection([
('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'), ('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'),
@@ -67,6 +92,16 @@ class EncoachResource(models.Model):
def to_api_dict(self): def to_api_dict(self):
self.ensure_one() self.ensure_one()
creator = self.creator_id 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 { return {
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
@@ -82,6 +117,10 @@ class EncoachResource(models.Model):
'learning_objective_names': self.learning_objective_ids.mapped('name'), 'learning_objective_names': self.learning_objective_ids.mapped('name'),
'url': self.url or '', 'url': self.url or '',
'has_file': bool(self.file), '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 '', 'difficulty': self.difficulty or '',
'duration_minutes': self.duration_minutes, 'duration_minutes': self.duration_minutes,
'author_id': creator.id if creator else None, 'author_id': creator.id if creator else None,
@@ -98,5 +137,5 @@ class EncoachResource(models.Model):
'approved': self.approved, 'approved': self.approved,
'course_count': self.course_count, 'course_count': self.course_count,
'created_at': self.create_date.isoformat() if self.create_date else '', 'created_at': self.create_date.isoformat() if self.create_date else '',
'file_name': self.name, 'file_name': self.original_filename or self.name,
} }