feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform. Made-with: Cursor
This commit is contained in:
@@ -18,6 +18,10 @@
|
|||||||
"depends": ["base", "encoach_core", "encoach_api"],
|
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
292
custom_addons/encoach_ai/controllers/ai_settings_controller.py
Normal file
292
custom_addons/encoach_ai/controllers/ai_settings_controller.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""Admin endpoints for AI provider selection and API-key management.
|
||||||
|
|
||||||
|
* ``GET /api/ai/settings/providers`` — current provider per capability,
|
||||||
|
redacted view of which API keys are present (booleans only — keys are
|
||||||
|
*never* echoed back), and the list of allowed providers per capability.
|
||||||
|
|
||||||
|
* ``PATCH /api/ai/settings/providers`` — write provider choices and/or
|
||||||
|
API keys to ``ir.config_parameter``. Settings take effect on the very
|
||||||
|
next request (no caching), so admins can flip providers without an
|
||||||
|
Odoo restart.
|
||||||
|
|
||||||
|
The controller is admin-gated:
|
||||||
|
|
||||||
|
* The caller must be authenticated (``@jwt_required``).
|
||||||
|
* The caller must have ``user_type == 'admin'`` *or* be in the
|
||||||
|
``base.group_system`` group. Anything else returns 403.
|
||||||
|
|
||||||
|
API-key fields are write-only over the wire. Sending an empty string
|
||||||
|
clears the key; omitting the field leaves it unchanged. The GET response
|
||||||
|
returns ``{"openai_key_set": true | false, ...}`` markers so the UI can
|
||||||
|
render a "saved · click to replace" state without ever leaking the
|
||||||
|
secret value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response, _error_response, _get_json_body,
|
||||||
|
)
|
||||||
|
from odoo.addons.encoach_ai.services import provider_router
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration tables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Provider keys per capability — mirrors provider_router.CAPABILITIES but
|
||||||
|
# adds UI labels and the "kind" so the frontend can render appropriate
|
||||||
|
# icons / disclaimers.
|
||||||
|
_PROVIDER_OPTIONS = {
|
||||||
|
'text': [
|
||||||
|
{'value': 'openai', 'label': 'OpenAI (GPT-4o)', 'kind': 'paid'},
|
||||||
|
{'value': 'mock', 'label': 'Mock (deterministic stub)', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
'image': [
|
||||||
|
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||||
|
{'value': 'openai', 'label': 'OpenAI (DALL-E 3)', 'kind': 'paid'},
|
||||||
|
{'value': 'pillow', 'label': 'Pillow placeholder (offline)', 'kind': 'free'},
|
||||||
|
{'value': 'unsplash', 'label': 'Unsplash Source (free, network)', 'kind': 'free'},
|
||||||
|
{'value': 'mock', 'label': 'Mock card', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
'audio': [
|
||||||
|
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||||
|
{'value': 'polly', 'label': 'AWS Polly (neural)', 'kind': 'paid'},
|
||||||
|
{'value': 'elevenlabs', 'label': 'ElevenLabs (multilingual)', 'kind': 'paid'},
|
||||||
|
{'value': 'gtts', 'label': 'gTTS (free, network)', 'kind': 'free'},
|
||||||
|
{'value': 'silent', 'label': 'Silent stub (offline)', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
'video': [
|
||||||
|
{'value': 'auto', 'label': 'Auto', 'kind': 'auto'},
|
||||||
|
{'value': 'ffmpeg', 'label': 'ffmpeg slideshow (image+audio)', 'kind': 'free'},
|
||||||
|
{'value': 'static', 'label': 'Static placeholder image', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-key params managed by this endpoint. Keys are write-only — the
|
||||||
|
# response only ever returns ``<name>_set: bool``.
|
||||||
|
_KEY_PARAMS = {
|
||||||
|
'openai_api_key': 'encoach_ai.openai_api_key',
|
||||||
|
'aws_access_key': 'encoach_ai.aws_access_key',
|
||||||
|
'aws_secret_key': 'encoach_ai.aws_secret_key',
|
||||||
|
'aws_region': 'encoach_ai.aws_region',
|
||||||
|
'elevenlabs_api_key': 'encoach_ai.elevenlabs_api_key',
|
||||||
|
'gptzero_api_key': 'encoach_ai.gptzero_api_key',
|
||||||
|
# Paymob (payments) — included so all platform secrets live in one UI
|
||||||
|
'paymob_api_key': 'encoach.paymob.api_key',
|
||||||
|
'paymob_integration_id': 'encoach.paymob.integration_id',
|
||||||
|
'paymob_iframe_id': 'encoach.paymob.iframe_id',
|
||||||
|
'paymob_hmac_secret': 'encoach.paymob.hmac_secret',
|
||||||
|
}
|
||||||
|
|
||||||
|
# These params don't carry secrets so we expose their plaintext values.
|
||||||
|
_PLAIN_PARAMS = {
|
||||||
|
'aws_region': 'encoach_ai.aws_region',
|
||||||
|
'openai_model': 'encoach_ai.openai_model',
|
||||||
|
'openai_fast_model': 'encoach_ai.openai_fast_model',
|
||||||
|
'elevenlabs_model': 'encoach_ai.elevenlabs_model',
|
||||||
|
'request_timeout': 'encoach_ai.request_timeout',
|
||||||
|
'max_retries': 'encoach_ai.max_retries',
|
||||||
|
'enabled': 'encoach_ai.enabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authorization
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(env):
|
||||||
|
"""Return True if the calling user is an EnCoach admin or system admin."""
|
||||||
|
user = env.user
|
||||||
|
if not user or not user.id:
|
||||||
|
return False
|
||||||
|
if user.has_group('base.group_system'):
|
||||||
|
return True
|
||||||
|
user_type = getattr(user, 'user_type', None)
|
||||||
|
return user_type == 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serialization helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read_state(env):
|
||||||
|
"""Build the full settings payload (no secrets in the output)."""
|
||||||
|
Param = env['ir.config_parameter'].sudo()
|
||||||
|
providers = {}
|
||||||
|
for cap, options in _PROVIDER_OPTIONS.items():
|
||||||
|
providers[cap] = {
|
||||||
|
'active': provider_router.get_active_provider(env, cap),
|
||||||
|
'options': options,
|
||||||
|
'paid_with_credentials': provider_router.get_paid_provider_keys(
|
||||||
|
env, cap,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
keys_set = {}
|
||||||
|
for short, full_param in _KEY_PARAMS.items():
|
||||||
|
# ``aws_region`` happens to live in both maps — it's not a secret,
|
||||||
|
# so we surface its value in ``plain`` and *also* mark it as set so
|
||||||
|
# the UI can show the field consistently.
|
||||||
|
val = Param.get_param(full_param)
|
||||||
|
keys_set[short] = bool(val)
|
||||||
|
plain = {short: Param.get_param(p, '')
|
||||||
|
for short, p in _PLAIN_PARAMS.items()}
|
||||||
|
return {
|
||||||
|
'providers': providers,
|
||||||
|
'keys_set': keys_set,
|
||||||
|
'plain': plain,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_clear_marker(value):
|
||||||
|
"""A trimmed, empty string explicitly clears the param."""
|
||||||
|
return isinstance(value, str) and value.strip() == ''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Controller
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AISettingsController(http.Controller):
|
||||||
|
"""REST surface for the AI Provider Settings admin page."""
|
||||||
|
|
||||||
|
@http.route('/api/ai/settings/providers',
|
||||||
|
type='http', auth='none', methods=['GET', 'OPTIONS'],
|
||||||
|
csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_providers(self, **kw):
|
||||||
|
if not _is_admin(request.env):
|
||||||
|
return _error_response('Admin access required', 403)
|
||||||
|
try:
|
||||||
|
return _json_response({'data': _read_state(request.env)})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('ai_settings.get failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/settings/providers',
|
||||||
|
type='http', auth='none', methods=['PATCH', 'POST', 'PUT'],
|
||||||
|
csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def patch_providers(self, **kw):
|
||||||
|
if not _is_admin(request.env):
|
||||||
|
return _error_response('Admin access required', 403)
|
||||||
|
try:
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
Param = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
# 1. Update active provider per capability — validate that the
|
||||||
|
# chosen value is one of the offered options to keep junk
|
||||||
|
# out of ir.config_parameter.
|
||||||
|
provider_updates = body.get('providers') or {}
|
||||||
|
invalid = []
|
||||||
|
for cap, value in provider_updates.items():
|
||||||
|
if cap not in _PROVIDER_OPTIONS:
|
||||||
|
invalid.append(f'unknown capability: {cap}')
|
||||||
|
continue
|
||||||
|
allowed = {o['value'] for o in _PROVIDER_OPTIONS[cap]}
|
||||||
|
if value not in allowed:
|
||||||
|
invalid.append(f'{cap}: {value!r} not in {sorted(allowed)}')
|
||||||
|
continue
|
||||||
|
Param.set_param(provider_router.CAPABILITIES[cap]['param'], value)
|
||||||
|
if invalid:
|
||||||
|
return _error_response(
|
||||||
|
'Invalid provider selections: ' + '; '.join(invalid), 400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Update API keys — write-only. An empty string clears the
|
||||||
|
# param; omitting the field leaves it untouched.
|
||||||
|
key_updates = body.get('keys') or {}
|
||||||
|
for short, value in key_updates.items():
|
||||||
|
if short not in _KEY_PARAMS:
|
||||||
|
continue
|
||||||
|
full_param = _KEY_PARAMS[short]
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if _is_clear_marker(value):
|
||||||
|
Param.set_param(full_param, '')
|
||||||
|
else:
|
||||||
|
# Trim whitespace to defend against a copy-paste with
|
||||||
|
# a trailing newline that would silently break SDK auth.
|
||||||
|
Param.set_param(full_param, str(value).strip())
|
||||||
|
|
||||||
|
# 3. Plain-value updates (model names, region, timeout, ...).
|
||||||
|
plain_updates = body.get('plain') or {}
|
||||||
|
for short, value in plain_updates.items():
|
||||||
|
if short not in _PLAIN_PARAMS:
|
||||||
|
continue
|
||||||
|
Param.set_param(_PLAIN_PARAMS[short], '' if value is None
|
||||||
|
else str(value))
|
||||||
|
|
||||||
|
# Audit log so admins can see who flipped providers.
|
||||||
|
try:
|
||||||
|
_logger.info(
|
||||||
|
'ai_settings.update by user_id=%s providers=%s '
|
||||||
|
'keys_changed=%s plain_changed=%s',
|
||||||
|
request.env.user.id,
|
||||||
|
list(provider_updates.keys()),
|
||||||
|
[k for k in key_updates.keys() if k in _KEY_PARAMS],
|
||||||
|
list(plain_updates.keys()),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _json_response({'data': _read_state(request.env)})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('ai_settings.patch failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/settings/providers/test',
|
||||||
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def test_provider(self, **kw):
|
||||||
|
"""Quick "is this configured?" probe for the UI's Test button.
|
||||||
|
|
||||||
|
Body: ``{"capability": "image" | "audio" | "text"}``.
|
||||||
|
|
||||||
|
Returns the resolved provider chain plus a flag for each entry
|
||||||
|
indicating whether credentials are present. We deliberately do
|
||||||
|
NOT make a real network call — we just resolve the chain and
|
||||||
|
check for credentials so the test is instant and free.
|
||||||
|
"""
|
||||||
|
if not _is_admin(request.env):
|
||||||
|
return _error_response('Admin access required', 403)
|
||||||
|
try:
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
capability = body.get('capability') or 'image'
|
||||||
|
if capability not in provider_router.CAPABILITIES:
|
||||||
|
return _error_response(
|
||||||
|
f'Unknown capability: {capability}', 400,
|
||||||
|
)
|
||||||
|
chain = provider_router.resolve_chain(request.env, capability)
|
||||||
|
paid_with_creds = set(
|
||||||
|
provider_router.get_paid_provider_keys(request.env, capability),
|
||||||
|
)
|
||||||
|
entries = []
|
||||||
|
for prov in chain:
|
||||||
|
if prov in provider_router.CAPABILITIES[capability]['paid']:
|
||||||
|
ok = prov in paid_with_creds
|
||||||
|
note = 'credentials configured' if ok else 'no API key'
|
||||||
|
else:
|
||||||
|
ok = True # free providers are always available
|
||||||
|
note = 'free fallback'
|
||||||
|
entries.append({'provider': prov, 'ok': ok, 'note': note})
|
||||||
|
return _json_response({
|
||||||
|
'capability': capability,
|
||||||
|
'active': provider_router.get_active_provider(
|
||||||
|
request.env, capability),
|
||||||
|
'chain': entries,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('ai_settings.test failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
150
custom_addons/encoach_ai/services/free_image.py
Normal file
150
custom_addons/encoach_ai/services/free_image.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Offline image placeholder generator using Pillow.
|
||||||
|
|
||||||
|
Used as a free fallback when DALL-E (or any paid image API) is missing
|
||||||
|
credentials, returns a billing/quota error, or is otherwise unavailable.
|
||||||
|
The resulting PNG is a clean education-themed gradient card with the
|
||||||
|
title overlaid — good enough to keep the LMS UI populated until a real
|
||||||
|
image is generated.
|
||||||
|
|
||||||
|
Pillow is the only runtime dependency (already a transitive dep of Odoo
|
||||||
|
through ``reportlab`` on most installs). If Pillow is not importable we
|
||||||
|
raise so the caller knows to skip this provider and try the next one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError: # pragma: no cover — Pillow is a soft dep
|
||||||
|
Image = None
|
||||||
|
ImageDraw = None
|
||||||
|
ImageFont = None
|
||||||
|
|
||||||
|
|
||||||
|
# Soft education palettes (top-left, bottom-right gradient pairs)
|
||||||
|
_PALETTES = [
|
||||||
|
((59, 130, 246), (147, 197, 253)), # blue
|
||||||
|
((16, 185, 129), (110, 231, 183)), # emerald
|
||||||
|
((245, 158, 11), (252, 211, 77)), # amber
|
||||||
|
((139, 92, 246), (196, 181, 253)), # violet
|
||||||
|
((236, 72, 153), (249, 168, 212)), # pink
|
||||||
|
((20, 184, 166), (153, 246, 228)), # teal
|
||||||
|
((99, 102, 241), (165, 180, 252)), # indigo
|
||||||
|
((220, 38, 38), (252, 165, 165)), # rose
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_size(size):
|
||||||
|
try:
|
||||||
|
w, h = (int(p) for p in str(size).lower().split('x'))
|
||||||
|
except Exception:
|
||||||
|
return 1024, 1024
|
||||||
|
return max(64, min(2048, w)), max(64, min(2048, h))
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(draw, text, font, max_width, max_lines=6):
|
||||||
|
words = (text or '').split()
|
||||||
|
lines, current = [], ''
|
||||||
|
for word in words:
|
||||||
|
candidate = (current + ' ' + word).strip()
|
||||||
|
if draw.textlength(candidate, font=font) <= max_width:
|
||||||
|
current = candidate
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
current = word
|
||||||
|
if len(lines) >= max_lines:
|
||||||
|
return lines
|
||||||
|
if current and len(lines) < max_lines:
|
||||||
|
lines.append(current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _load_font(preferred, size):
|
||||||
|
"""Try a list of fonts; fall back to the bundled default at any size."""
|
||||||
|
for name in preferred:
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(name, size=size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def render_placeholder(title, *, subtitle=None, size='1024x1024', seed=None):
|
||||||
|
"""Return PNG bytes for a placeholder card.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Main title text rendered large and centred.
|
||||||
|
subtitle: Optional smaller line below the title (e.g. CEFR level,
|
||||||
|
week label) — pass ``None`` to skip.
|
||||||
|
size: ``"WIDTHxHEIGHT"`` string, e.g. ``"1024x1024"``.
|
||||||
|
seed: Optional seed for palette selection so the same title yields
|
||||||
|
the same gradient repeatably.
|
||||||
|
"""
|
||||||
|
if Image is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Pillow not installed — pip install Pillow to enable the free '
|
||||||
|
'image fallback.'
|
||||||
|
)
|
||||||
|
w, h = _parse_size(size)
|
||||||
|
rng = random.Random(seed if seed is not None else (title or '').lower())
|
||||||
|
top, bottom = rng.choice(_PALETTES)
|
||||||
|
|
||||||
|
img = Image.new('RGB', (w, h), top)
|
||||||
|
px = img.load()
|
||||||
|
for y in range(h):
|
||||||
|
t = y / max(1, h - 1)
|
||||||
|
r = int(top[0] * (1 - t) + bottom[0] * t)
|
||||||
|
g = int(top[1] * (1 - t) + bottom[1] * t)
|
||||||
|
b = int(top[2] * (1 - t) + bottom[2] * t)
|
||||||
|
for x in range(w):
|
||||||
|
px[x, y] = (r, g, b)
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
title_font = _load_font(
|
||||||
|
['DejaVuSans-Bold.ttf', 'Arial Bold.ttf', 'arial.ttf'],
|
||||||
|
int(h * 0.07),
|
||||||
|
)
|
||||||
|
sub_font = _load_font(
|
||||||
|
['DejaVuSans.ttf', 'Arial.ttf', 'arial.ttf'],
|
||||||
|
int(h * 0.035),
|
||||||
|
)
|
||||||
|
|
||||||
|
margin = int(w * 0.08)
|
||||||
|
max_text_w = w - 2 * margin
|
||||||
|
title_lines = _wrap(draw, title or 'Untitled', title_font, max_text_w)
|
||||||
|
line_h = int(h * 0.085)
|
||||||
|
total_h = line_h * len(title_lines)
|
||||||
|
y = (h - total_h) // 2
|
||||||
|
for line in title_lines:
|
||||||
|
line_w = draw.textlength(line, font=title_font)
|
||||||
|
x = (w - line_w) // 2
|
||||||
|
draw.text((x + 2, y + 2), line, font=title_font, fill=(0, 0, 0))
|
||||||
|
draw.text((x, y), line, font=title_font, fill=(255, 255, 255))
|
||||||
|
y += line_h
|
||||||
|
|
||||||
|
if subtitle:
|
||||||
|
sub_w = draw.textlength(subtitle, font=sub_font)
|
||||||
|
sx = (w - sub_w) // 2
|
||||||
|
sy = y + int(h * 0.02)
|
||||||
|
draw.text((sx + 1, sy + 1), subtitle, font=sub_font, fill=(0, 0, 0))
|
||||||
|
draw.text((sx, sy), subtitle, font=sub_font, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
# Subtle EnCoach watermark badge so generated assets are easy to spot
|
||||||
|
badge_font = _load_font(['DejaVuSans.ttf', 'Arial.ttf'], int(h * 0.022))
|
||||||
|
badge = 'EnCoach · placeholder'
|
||||||
|
bw = draw.textlength(badge, font=badge_font)
|
||||||
|
draw.text(
|
||||||
|
(w - margin - bw, h - margin),
|
||||||
|
badge, font=badge_font, fill=(255, 255, 255),
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG', optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
128
custom_addons/encoach_ai/services/free_tts.py
Normal file
128
custom_addons/encoach_ai/services/free_tts.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Free / offline text-to-speech fallbacks.
|
||||||
|
|
||||||
|
Two providers, ordered most-useful-first:
|
||||||
|
|
||||||
|
1. ``gtts`` — Google Translate TTS. Free and surprisingly natural, but
|
||||||
|
requires outbound network access. We try this first when
|
||||||
|
the paid provider is exhausted.
|
||||||
|
|
||||||
|
2. ``silent`` — A pre-encoded silent MP3 (~1 second). Used as a last
|
||||||
|
resort so that downstream consumers (notably the video
|
||||||
|
composer) still receive valid audio bytes and don't crash.
|
||||||
|
|
||||||
|
The shape of the return dict matches what ``PollyService.synthesize``
|
||||||
|
returns (``audio``, ``content_type``, ``voice``, ``characters``) so
|
||||||
|
callers can swap providers transparently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try: # pragma: no cover — gTTS is a soft dep
|
||||||
|
from gtts import gTTS
|
||||||
|
except ImportError:
|
||||||
|
gTTS = None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_silent_wav(duration_seconds: float = 1.0,
|
||||||
|
sample_rate: int = 8000) -> bytes:
|
||||||
|
"""Construct a valid PCM WAV byte string with all-zero samples.
|
||||||
|
|
||||||
|
WAV is trivially constructable from primitives so we can produce it
|
||||||
|
without any external library. ffmpeg accepts WAV as readily as MP3,
|
||||||
|
so the rest of the pipeline is unaffected by the format choice.
|
||||||
|
"""
|
||||||
|
n_samples = max(1, int(duration_seconds * sample_rate))
|
||||||
|
samples = b'\x00\x00' * n_samples # 16-bit mono silence
|
||||||
|
data_size = len(samples)
|
||||||
|
fmt_chunk = (
|
||||||
|
b'fmt '
|
||||||
|
+ struct.pack('<I', 16) # PCM fmt chunk size
|
||||||
|
+ struct.pack('<H', 1) # PCM
|
||||||
|
+ struct.pack('<H', 1) # mono
|
||||||
|
+ struct.pack('<I', sample_rate)
|
||||||
|
+ struct.pack('<I', sample_rate * 2) # byte rate
|
||||||
|
+ struct.pack('<H', 2) # block align
|
||||||
|
+ struct.pack('<H', 16) # bits per sample
|
||||||
|
)
|
||||||
|
data_chunk = b'data' + struct.pack('<I', data_size) + samples
|
||||||
|
return (
|
||||||
|
b'RIFF'
|
||||||
|
+ struct.pack('<I', 36 + data_size)
|
||||||
|
+ b'WAVE'
|
||||||
|
+ fmt_chunk
|
||||||
|
+ data_chunk
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Map our internal language codes to (gTTS lang, gTTS tld) tuples. The
|
||||||
|
# tld controls accent (co.uk vs com vs com.au) so picking carefully here
|
||||||
|
# gives the listening exam a more authentic accent.
|
||||||
|
_GTTS_LANG_MAP = {
|
||||||
|
'en-GB': ('en', 'co.uk'),
|
||||||
|
'en-US': ('en', 'com'),
|
||||||
|
'en-AU': ('en', 'com.au'),
|
||||||
|
'en-IN': ('en', 'co.in'),
|
||||||
|
'en': ('en', 'co.uk'),
|
||||||
|
'ar': ('ar', 'com'),
|
||||||
|
'ar-EG': ('ar', 'com'),
|
||||||
|
'ar-SA': ('ar', 'com'),
|
||||||
|
'fr': ('fr', 'fr'),
|
||||||
|
'fr-FR': ('fr', 'fr'),
|
||||||
|
'es': ('es', 'es'),
|
||||||
|
'de': ('de', 'de'),
|
||||||
|
'tr': ('tr', 'com'),
|
||||||
|
'fa': ('fa', 'com'),
|
||||||
|
'ur': ('ur', 'com'),
|
||||||
|
'hi': ('hi', 'co.in'),
|
||||||
|
'zh': ('zh-CN', 'com'),
|
||||||
|
'ja': ('ja', 'com'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_with_gtts(text, *, language='en-GB'):
|
||||||
|
"""Synthesize ``text`` to MP3 bytes using gTTS.
|
||||||
|
|
||||||
|
Raises ``RuntimeError`` if gTTS is not installed; the caller is
|
||||||
|
expected to catch and try the next provider in the chain.
|
||||||
|
"""
|
||||||
|
if gTTS is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
'gTTS not installed — pip install gTTS to enable the free '
|
||||||
|
'audio fallback.'
|
||||||
|
)
|
||||||
|
short = (text or '')[:4500]
|
||||||
|
if not short.strip():
|
||||||
|
return synthesize_silent()
|
||||||
|
lang, tld = _GTTS_LANG_MAP.get(language, ('en', 'co.uk'))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
tts = gTTS(text=short, lang=lang, tld=tld, slow=False)
|
||||||
|
tts.write_to_fp(buf)
|
||||||
|
return {
|
||||||
|
'audio': buf.getvalue(),
|
||||||
|
'content_type': 'audio/mpeg',
|
||||||
|
'voice': f'gtts-{lang}-{tld}',
|
||||||
|
'characters': len(short),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_silent(duration_seconds=1):
|
||||||
|
"""Return a minimal valid silent audio stub.
|
||||||
|
|
||||||
|
Returns a PCM WAV (which ffmpeg accepts identically to MP3) of the
|
||||||
|
requested duration. Used when even gTTS is unreachable and we just
|
||||||
|
need *some* valid audio so the video composer doesn't fail and the
|
||||||
|
media row can still be marked ``ready``.
|
||||||
|
"""
|
||||||
|
payload = _build_silent_wav(duration_seconds=duration_seconds)
|
||||||
|
return {
|
||||||
|
'audio': payload,
|
||||||
|
'content_type': 'audio/wav',
|
||||||
|
'voice': 'silent-stub',
|
||||||
|
'characters': 0,
|
||||||
|
}
|
||||||
@@ -64,7 +64,18 @@ class OpenAIService:
|
|||||||
import os
|
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:
|
||||||
|
|||||||
187
custom_addons/encoach_ai/services/provider_router.py
Normal file
187
custom_addons/encoach_ai/services/provider_router.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Resolve the active AI provider per capability.
|
||||||
|
|
||||||
|
Settings are read fresh from ``ir.config_parameter`` on every call — there
|
||||||
|
is intentionally NO module-level cache so flipping a provider in the admin
|
||||||
|
UI takes effect on the next request without restarting Odoo.
|
||||||
|
|
||||||
|
Public surface:
|
||||||
|
|
||||||
|
* :func:`get_active_provider(env, capability)` — returns the provider key
|
||||||
|
configured for ``capability`` (one of ``text|image|audio|video``).
|
||||||
|
* :func:`classify_provider_error(exc)` — turns an arbitrary exception from
|
||||||
|
any third-party SDK into one of ``quota|auth|network|other`` so callers
|
||||||
|
can decide whether to fall back automatically.
|
||||||
|
* :class:`ProviderQuotaError`, :class:`ProviderAuthError` — typed errors
|
||||||
|
that callers may raise when they want a strongly-typed signal.
|
||||||
|
|
||||||
|
The capability tables also enumerate the **allowed free providers** per
|
||||||
|
capability — these are the ones the fallback chain uses when the paid
|
||||||
|
provider returns a quota or auth error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Capabilities & provider names
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ``auto`` means "pick the first paid provider that's configured, else fall
|
||||||
|
# back to the first free provider that works". Admins who want to *force* a
|
||||||
|
# specific provider should pick its name explicitly in the UI.
|
||||||
|
|
||||||
|
CAPABILITIES = {
|
||||||
|
'text': {
|
||||||
|
'param': 'encoach.ai.text_provider',
|
||||||
|
'default': 'openai',
|
||||||
|
'paid': ['openai'],
|
||||||
|
'free': ['mock'],
|
||||||
|
},
|
||||||
|
'image': {
|
||||||
|
'param': 'encoach.ai.image_provider',
|
||||||
|
'default': 'auto',
|
||||||
|
'paid': ['openai'],
|
||||||
|
'free': ['pillow', 'unsplash', 'mock'],
|
||||||
|
},
|
||||||
|
'audio': {
|
||||||
|
'param': 'encoach.ai.audio_provider',
|
||||||
|
'default': 'auto',
|
||||||
|
'paid': ['polly', 'elevenlabs'],
|
||||||
|
'free': ['gtts', 'silent'],
|
||||||
|
},
|
||||||
|
'video': {
|
||||||
|
'param': 'encoach.ai.video_provider',
|
||||||
|
'default': 'auto',
|
||||||
|
'paid': [],
|
||||||
|
'free': ['ffmpeg', 'static'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typed errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderQuotaError(RuntimeError):
|
||||||
|
"""Raised when a provider returns a billing/quota error.
|
||||||
|
|
||||||
|
Examples include OpenAI ``insufficient_quota``, AWS Polly
|
||||||
|
``ThrottlingException``, ElevenLabs character-limit rejection, and any
|
||||||
|
HTTP 402/429. Callers should treat this as a soft failure and try the
|
||||||
|
next provider in the fallback chain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderAuthError(RuntimeError):
|
||||||
|
"""Raised when a provider refuses authentication (missing/invalid key)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_provider(env, capability):
|
||||||
|
"""Read the currently-active provider key for ``capability``.
|
||||||
|
|
||||||
|
Always reads from ``ir.config_parameter`` — never cached.
|
||||||
|
"""
|
||||||
|
cap = CAPABILITIES[capability]
|
||||||
|
return env['ir.config_parameter'].sudo().get_param(
|
||||||
|
cap['param'], cap['default'],
|
||||||
|
) or cap['default']
|
||||||
|
|
||||||
|
|
||||||
|
def get_paid_provider_keys(env, capability):
|
||||||
|
"""Return the list of paid providers for the capability that have an
|
||||||
|
API key configured. Used to decide whether to skip straight to free
|
||||||
|
fallbacks when ``auto`` is selected but no paid keys exist.
|
||||||
|
"""
|
||||||
|
Param = env['ir.config_parameter'].sudo()
|
||||||
|
available = []
|
||||||
|
for provider in CAPABILITIES[capability]['paid']:
|
||||||
|
if _provider_has_credentials(Param, provider):
|
||||||
|
available.append(provider)
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_has_credentials(Param, provider):
|
||||||
|
if provider == 'openai':
|
||||||
|
return bool(Param.get_param('encoach_ai.openai_api_key'))
|
||||||
|
if provider == 'polly':
|
||||||
|
return bool(Param.get_param('encoach_ai.aws_access_key')) and bool(
|
||||||
|
Param.get_param('encoach_ai.aws_secret_key')
|
||||||
|
)
|
||||||
|
if provider == 'elevenlabs':
|
||||||
|
return bool(Param.get_param('encoach_ai.elevenlabs_api_key'))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_chain(env, capability, *, requested=None):
|
||||||
|
"""Return an ordered list of providers to try for ``capability``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
capability: ``'text' | 'image' | 'audio' | 'video'``.
|
||||||
|
requested: optional explicit provider override (e.g. body param).
|
||||||
|
When supplied it goes to the front of the chain.
|
||||||
|
"""
|
||||||
|
cap = CAPABILITIES[capability]
|
||||||
|
chain = []
|
||||||
|
selected = (requested or get_active_provider(env, capability) or '').strip()
|
||||||
|
|
||||||
|
if selected and selected != 'auto':
|
||||||
|
chain.append(selected)
|
||||||
|
|
||||||
|
# Paid providers with credentials, then free fallbacks
|
||||||
|
if selected == 'auto' or not selected:
|
||||||
|
for p in get_paid_provider_keys(env, capability):
|
||||||
|
if p not in chain:
|
||||||
|
chain.append(p)
|
||||||
|
for p in cap['free']:
|
||||||
|
if p not in chain:
|
||||||
|
chain.append(p)
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error classification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_QUOTA_TOKENS = (
|
||||||
|
'insufficient_quota', 'quota', 'billing',
|
||||||
|
'429', '402',
|
||||||
|
'rate limit', 'rate_limit', 'rate-limit',
|
||||||
|
'throttlingexception', 'thresholdexceeded',
|
||||||
|
'limit_exceeded', 'character limit', 'too many requests',
|
||||||
|
)
|
||||||
|
_AUTH_TOKENS = (
|
||||||
|
'invalid_api_key', 'incorrect api key', 'authentication',
|
||||||
|
'unauthorized', 'access denied', '401', '403',
|
||||||
|
'authenticationerror', 'permissiondenied', 'invalid api key',
|
||||||
|
'missing api key',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_provider_error(exc):
|
||||||
|
"""Map any provider exception to one of: ``quota|auth|network|other``."""
|
||||||
|
msg = (str(exc) or '').lower()
|
||||||
|
name = type(exc).__name__.lower()
|
||||||
|
blob = msg + ' ' + name
|
||||||
|
if any(t in blob for t in _QUOTA_TOKENS):
|
||||||
|
return 'quota'
|
||||||
|
if any(t in blob for t in _AUTH_TOKENS):
|
||||||
|
return 'auth'
|
||||||
|
if 'timeout' in blob or 'connection' in blob or 'network' in msg:
|
||||||
|
return 'network'
|
||||||
|
return 'other'
|
||||||
|
|
||||||
|
|
||||||
|
def should_fallback(exc):
|
||||||
|
"""Whether the caller should try the next provider in the chain."""
|
||||||
|
return classify_provider_error(exc) in ('quota', 'auth', 'network')
|
||||||
@@ -7,12 +7,14 @@ decorator and returns JSON.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import 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({
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user