Compare commits
12 Commits
fa6f4976c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af33d9d19e | ||
| 3500896c15 | |||
|
|
35ccc0dfb2 | ||
|
|
096b042daf | ||
|
|
0ed7f88cab | ||
|
|
ed8e75d88c | ||
|
|
971e9860c8 | ||
|
|
8d173b93cb | ||
| a5a3a2dc62 | |||
| b1b3d20eb4 | |||
| e33a9a61bb | |||
| 7024197c7b |
49
.gitea/workflows/deploy.yml
Normal file
49
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Deploy to Staging
|
||||
|
||||
# Triggered on every push to main (after PR merge).
|
||||
# Runs ONLY the deploy job — not tests — so the server
|
||||
# always reflects the latest main without waiting for the
|
||||
# full CI suite to finish. The existing ci.yml handles lint/tests.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-backend
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy backend + frontend to staging
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Pull latest code
|
||||
run: |
|
||||
cd /opt/encoach/encoach_backend_new_v2
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
echo "Now at: $(git log -1 --oneline)"
|
||||
|
||||
- name: Rebuild and restart containers
|
||||
run: |
|
||||
cd /opt/encoach/encoach_backend_new_v2
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f /opt/encoach/overrides/encoach.override.yml \
|
||||
up -d --build --remove-orphans
|
||||
echo "Containers after deploy:"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "encoach-v4|encoach-frontend"
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
echo "Waiting 20s for Odoo to settle..."
|
||||
sleep 20
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8069/api/health)
|
||||
echo "/api/health => HTTP $STATUS"
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "WARNING: /api/health returned $STATUS (Odoo may still be starting)"
|
||||
fi
|
||||
FE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
|
||||
echo "frontend / => HTTP $FE"
|
||||
test "$FE" = "200" || exit 1
|
||||
@@ -18,6 +18,10 @@
|
||||
"depends": ["base", "encoach_core", "encoach_api"],
|
||||
"external_dependencies": {
|
||||
"python": ["openai", "boto3", "langgraph", "langchain_core"],
|
||||
# Soft deps used only by free media fallbacks; the platform still
|
||||
# boots and works fine without them — see services/free_image.py
|
||||
# and services/free_tts.py for graceful import-failure handling.
|
||||
# Add to a real requirements file: ``pip install Pillow gTTS``.
|
||||
},
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
|
||||
@@ -4,3 +4,4 @@ from . import media_controller
|
||||
from . import prompt_controller
|
||||
from . import feedback_controller
|
||||
from . import agents_controller
|
||||
from . import ai_settings_controller
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Admin endpoints for AI provider selection and API-key management.
|
||||
|
||||
* ``GET /api/ai/settings/providers`` — current provider per capability,
|
||||
redacted view of which API keys are present (booleans only — keys are
|
||||
*never* echoed back), and the list of allowed providers per capability.
|
||||
|
||||
* ``PATCH /api/ai/settings/providers`` — write provider choices and/or
|
||||
API keys to ``ir.config_parameter``. Settings take effect on the very
|
||||
next request (no caching), so admins can flip providers without an
|
||||
Odoo restart.
|
||||
|
||||
The controller is admin-gated:
|
||||
|
||||
* The caller must be authenticated (``@jwt_required``).
|
||||
* The caller must have ``user_type == 'admin'`` *or* be in the
|
||||
``base.group_system`` group. Anything else returns 403.
|
||||
|
||||
API-key fields are write-only over the wire. Sending an empty string
|
||||
clears the key; omitting the field leaves it unchanged. The GET response
|
||||
returns ``{"openai_key_set": true | false, ...}`` markers so the UI can
|
||||
render a "saved · click to replace" state without ever leaking the
|
||||
secret value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body,
|
||||
)
|
||||
from odoo.addons.encoach_ai.services import provider_router
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Provider keys per capability — mirrors provider_router.CAPABILITIES but
|
||||
# adds UI labels and the "kind" so the frontend can render appropriate
|
||||
# icons / disclaimers.
|
||||
_PROVIDER_OPTIONS = {
|
||||
'text': [
|
||||
{'value': 'openai', 'label': 'OpenAI (GPT-4o)', 'kind': 'paid'},
|
||||
{'value': 'mock', 'label': 'Mock (deterministic stub)', 'kind': 'free'},
|
||||
],
|
||||
'image': [
|
||||
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||
{'value': 'openai', 'label': 'OpenAI (DALL-E 3)', 'kind': 'paid'},
|
||||
{'value': 'pillow', 'label': 'Pillow placeholder (offline)', 'kind': 'free'},
|
||||
{'value': 'unsplash', 'label': 'Unsplash Source (free, network)', 'kind': 'free'},
|
||||
{'value': 'mock', 'label': 'Mock card', 'kind': 'free'},
|
||||
],
|
||||
'audio': [
|
||||
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||
{'value': 'polly', 'label': 'AWS Polly (neural)', 'kind': 'paid'},
|
||||
{'value': 'elevenlabs', 'label': 'ElevenLabs (multilingual)', 'kind': 'paid'},
|
||||
{'value': 'gtts', 'label': 'gTTS (free, network)', 'kind': 'free'},
|
||||
{'value': 'silent', 'label': 'Silent stub (offline)', 'kind': 'free'},
|
||||
],
|
||||
'video': [
|
||||
{'value': 'auto', 'label': 'Auto', 'kind': 'auto'},
|
||||
{'value': 'ffmpeg', 'label': 'ffmpeg slideshow (image+audio)', 'kind': 'free'},
|
||||
{'value': 'static', 'label': 'Static placeholder image', 'kind': 'free'},
|
||||
],
|
||||
}
|
||||
|
||||
# API-key params managed by this endpoint. Keys are write-only — the
|
||||
# response only ever returns ``<name>_set: bool``.
|
||||
_KEY_PARAMS = {
|
||||
'openai_api_key': 'encoach_ai.openai_api_key',
|
||||
'aws_access_key': 'encoach_ai.aws_access_key',
|
||||
'aws_secret_key': 'encoach_ai.aws_secret_key',
|
||||
'aws_region': 'encoach_ai.aws_region',
|
||||
'elevenlabs_api_key': 'encoach_ai.elevenlabs_api_key',
|
||||
'gptzero_api_key': 'encoach_ai.gptzero_api_key',
|
||||
# Paymob (payments) — included so all platform secrets live in one UI
|
||||
'paymob_api_key': 'encoach.paymob.api_key',
|
||||
'paymob_integration_id': 'encoach.paymob.integration_id',
|
||||
'paymob_iframe_id': 'encoach.paymob.iframe_id',
|
||||
'paymob_hmac_secret': 'encoach.paymob.hmac_secret',
|
||||
}
|
||||
|
||||
# These params don't carry secrets so we expose their plaintext values.
|
||||
_PLAIN_PARAMS = {
|
||||
'aws_region': 'encoach_ai.aws_region',
|
||||
'openai_model': 'encoach_ai.openai_model',
|
||||
'openai_fast_model': 'encoach_ai.openai_fast_model',
|
||||
'elevenlabs_model': 'encoach_ai.elevenlabs_model',
|
||||
'request_timeout': 'encoach_ai.request_timeout',
|
||||
'max_retries': 'encoach_ai.max_retries',
|
||||
'enabled': 'encoach_ai.enabled',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_admin(env):
|
||||
"""Return True if the calling user is an EnCoach admin or system admin."""
|
||||
user = env.user
|
||||
if not user or not user.id:
|
||||
return False
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
user_type = getattr(user, 'user_type', None)
|
||||
return user_type == 'admin'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_state(env):
|
||||
"""Build the full settings payload (no secrets in the output)."""
|
||||
Param = env['ir.config_parameter'].sudo()
|
||||
providers = {}
|
||||
for cap, options in _PROVIDER_OPTIONS.items():
|
||||
providers[cap] = {
|
||||
'active': provider_router.get_active_provider(env, cap),
|
||||
'options': options,
|
||||
'paid_with_credentials': provider_router.get_paid_provider_keys(
|
||||
env, cap,
|
||||
),
|
||||
}
|
||||
keys_set = {}
|
||||
for short, full_param in _KEY_PARAMS.items():
|
||||
# ``aws_region`` happens to live in both maps — it's not a secret,
|
||||
# so we surface its value in ``plain`` and *also* mark it as set so
|
||||
# the UI can show the field consistently.
|
||||
val = Param.get_param(full_param)
|
||||
keys_set[short] = bool(val)
|
||||
plain = {short: Param.get_param(p, '')
|
||||
for short, p in _PLAIN_PARAMS.items()}
|
||||
return {
|
||||
'providers': providers,
|
||||
'keys_set': keys_set,
|
||||
'plain': plain,
|
||||
}
|
||||
|
||||
|
||||
def _is_clear_marker(value):
|
||||
"""A trimmed, empty string explicitly clears the param."""
|
||||
return isinstance(value, str) and value.strip() == ''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Controller
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AISettingsController(http.Controller):
|
||||
"""REST surface for the AI Provider Settings admin page."""
|
||||
|
||||
@http.route('/api/ai/settings/providers',
|
||||
type='http', auth='none', methods=['GET', 'OPTIONS'],
|
||||
csrf=False)
|
||||
@jwt_required
|
||||
def get_providers(self, **kw):
|
||||
if not _is_admin(request.env):
|
||||
return _error_response('Admin access required', 403)
|
||||
try:
|
||||
return _json_response({'data': _read_state(request.env)})
|
||||
except Exception as exc:
|
||||
_logger.exception('ai_settings.get failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@http.route('/api/ai/settings/providers',
|
||||
type='http', auth='none', methods=['PATCH', 'POST', 'PUT'],
|
||||
csrf=False)
|
||||
@jwt_required
|
||||
def patch_providers(self, **kw):
|
||||
if not _is_admin(request.env):
|
||||
return _error_response('Admin access required', 403)
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
Param = request.env['ir.config_parameter'].sudo()
|
||||
|
||||
# 1. Update active provider per capability — validate that the
|
||||
# chosen value is one of the offered options to keep junk
|
||||
# out of ir.config_parameter.
|
||||
provider_updates = body.get('providers') or {}
|
||||
invalid = []
|
||||
for cap, value in provider_updates.items():
|
||||
if cap not in _PROVIDER_OPTIONS:
|
||||
invalid.append(f'unknown capability: {cap}')
|
||||
continue
|
||||
allowed = {o['value'] for o in _PROVIDER_OPTIONS[cap]}
|
||||
if value not in allowed:
|
||||
invalid.append(f'{cap}: {value!r} not in {sorted(allowed)}')
|
||||
continue
|
||||
Param.set_param(provider_router.CAPABILITIES[cap]['param'], value)
|
||||
if invalid:
|
||||
return _error_response(
|
||||
'Invalid provider selections: ' + '; '.join(invalid), 400,
|
||||
)
|
||||
|
||||
# 2. Update API keys — write-only. An empty string clears the
|
||||
# param; omitting the field leaves it untouched.
|
||||
key_updates = body.get('keys') or {}
|
||||
for short, value in key_updates.items():
|
||||
if short not in _KEY_PARAMS:
|
||||
continue
|
||||
full_param = _KEY_PARAMS[short]
|
||||
if value is None:
|
||||
continue
|
||||
if _is_clear_marker(value):
|
||||
Param.set_param(full_param, '')
|
||||
else:
|
||||
# Trim whitespace to defend against a copy-paste with
|
||||
# a trailing newline that would silently break SDK auth.
|
||||
Param.set_param(full_param, str(value).strip())
|
||||
|
||||
# 3. Plain-value updates (model names, region, timeout, ...).
|
||||
plain_updates = body.get('plain') or {}
|
||||
for short, value in plain_updates.items():
|
||||
if short not in _PLAIN_PARAMS:
|
||||
continue
|
||||
Param.set_param(_PLAIN_PARAMS[short], '' if value is None
|
||||
else str(value))
|
||||
|
||||
# Audit log so admins can see who flipped providers.
|
||||
try:
|
||||
_logger.info(
|
||||
'ai_settings.update by user_id=%s providers=%s '
|
||||
'keys_changed=%s plain_changed=%s',
|
||||
request.env.user.id,
|
||||
list(provider_updates.keys()),
|
||||
[k for k in key_updates.keys() if k in _KEY_PARAMS],
|
||||
list(plain_updates.keys()),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _json_response({'data': _read_state(request.env)})
|
||||
except Exception as exc:
|
||||
_logger.exception('ai_settings.patch failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@http.route('/api/ai/settings/providers/test',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def test_provider(self, **kw):
|
||||
"""Quick "is this configured?" probe for the UI's Test button.
|
||||
|
||||
Body: ``{"capability": "image" | "audio" | "text"}``.
|
||||
|
||||
Returns the resolved provider chain plus a flag for each entry
|
||||
indicating whether credentials are present. We deliberately do
|
||||
NOT make a real network call — we just resolve the chain and
|
||||
check for credentials so the test is instant and free.
|
||||
"""
|
||||
if not _is_admin(request.env):
|
||||
return _error_response('Admin access required', 403)
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
capability = body.get('capability') or 'image'
|
||||
if capability not in provider_router.CAPABILITIES:
|
||||
return _error_response(
|
||||
f'Unknown capability: {capability}', 400,
|
||||
)
|
||||
chain = provider_router.resolve_chain(request.env, capability)
|
||||
paid_with_creds = set(
|
||||
provider_router.get_paid_provider_keys(request.env, capability),
|
||||
)
|
||||
entries = []
|
||||
for prov in chain:
|
||||
if prov in provider_router.CAPABILITIES[capability]['paid']:
|
||||
ok = prov in paid_with_creds
|
||||
note = 'credentials configured' if ok else 'no API key'
|
||||
else:
|
||||
ok = True # free providers are always available
|
||||
note = 'free fallback'
|
||||
entries.append({'provider': prov, 'ok': ok, 'note': note})
|
||||
return _json_response({
|
||||
'capability': capability,
|
||||
'active': provider_router.get_active_provider(
|
||||
request.env, capability),
|
||||
'chain': entries,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('ai_settings.test failed')
|
||||
return _error_response(str(exc), 500)
|
||||
@@ -99,6 +99,37 @@
|
||||
<field name="sequence">90</field>
|
||||
</record>
|
||||
|
||||
<!-- Media generation -->
|
||||
<record id="ai_tool_media_audio" model="encoach.ai.tool">
|
||||
<field name="key">media.synthesize_audio</field>
|
||||
<field name="name">Synthesize narration audio</field>
|
||||
<field name="category">media</field>
|
||||
<field name="mutates" eval="True"/>
|
||||
<field name="description">Render an MP3 narration of a material's text using AWS Polly (default) or ElevenLabs. Used for listening_script and speaking_prompt materials. Returns the media id, status and a /web/content URL when ready.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"},"voice":{"type":"string"},"language":{"type":"string","default":"en-GB"},"gender":{"type":"string","enum":["female","male"]},"provider":{"type":"string","enum":["polly","elevenlabs"]}},"required":["material_id"]}</field>
|
||||
<field name="sequence">120</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_media_image" model="encoach.ai.tool">
|
||||
<field name="key">media.generate_image</field>
|
||||
<field name="name">Generate illustration (DALL-E)</field>
|
||||
<field name="category">media</field>
|
||||
<field name="mutates" eval="True"/>
|
||||
<field name="description">Generate a single PNG illustration with OpenAI DALL-E 3 for a course-plan material. Used for reading hero images and vocabulary flashcards. Honours the per-plan image budget (encoach_ai_course.image_budget_per_plan).</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"},"prompt":{"type":"string"},"size":{"type":"string","enum":["1024x1024","1024x1792","1792x1024"]},"style":{"type":"string","enum":["natural","vivid"]},"quality":{"type":"string","enum":["standard","hd"]}},"required":["material_id"]}</field>
|
||||
<field name="sequence">130</field>
|
||||
</record>
|
||||
|
||||
<record id="ai_tool_media_video" model="encoach.ai.tool">
|
||||
<field name="key">media.compose_video</field>
|
||||
<field name="name">Compose slideshow video (ffmpeg)</field>
|
||||
<field name="category">media</field>
|
||||
<field name="mutates" eval="True"/>
|
||||
<field name="description">Combine an existing audio narration with a still image into an MP4 (1280x720) using a local ffmpeg subprocess. Auto-creates audio and/or image first if the material lacks them. Falls back gracefully when ffmpeg is missing on the server.</field>
|
||||
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"}},"required":["material_id"]}</field>
|
||||
<field name="sequence">140</field>
|
||||
</record>
|
||||
|
||||
<!-- Scoring -->
|
||||
<record id="ai_tool_scoring_writing" model="encoach.ai.tool">
|
||||
<field name="key">scoring.grade_writing</field>
|
||||
@@ -179,6 +210,8 @@ Rules:
|
||||
ref('ai_tool_resources_search'),
|
||||
ref('ai_tool_quality_cefr'),
|
||||
ref('ai_tool_course_plan_save_materials'),
|
||||
ref('ai_tool_media_audio'),
|
||||
ref('ai_tool_media_image'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
@@ -328,6 +361,46 @@ Rules:
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- 8. Course media director -->
|
||||
<record id="ai_agent_course_media_director" model="encoach.ai.agent">
|
||||
<field name="key">course_media_director</field>
|
||||
<field name="name">Course Media Director</field>
|
||||
<field name="description">Given a generated week of teaching materials, decides which media (audio narration, illustrations, slideshow video) each material needs and orchestrates their generation through the media tools.</field>
|
||||
<field name="model">gpt-4o-mini</field>
|
||||
<field name="fallback_model">gpt-4o</field>
|
||||
<field name="temperature">0.3</field>
|
||||
<field name="max_tokens">2000</field>
|
||||
<field name="response_format">text</field>
|
||||
<field name="graph_type">react</field>
|
||||
<field name="max_revisions">0</field>
|
||||
<field name="quality_checks"></field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="system_prompt">You are the multimedia director for an English language course. Given the materials of one week, you decide what media each material needs, then call the matching tools.
|
||||
|
||||
Default policy:
|
||||
- listening_script -> media.synthesize_audio (mandatory) + media.generate_image (optional, scene illustration) + media.compose_video (optional, slideshow)
|
||||
- speaking_prompt -> media.synthesize_audio for the model answer (optional)
|
||||
- reading_text -> media.generate_image for a hero illustration (optional)
|
||||
- vocabulary_list -> media.generate_image for the first 1-3 terms (use vocab term in the prompt)
|
||||
- writing_prompt / grammar_lesson -> usually no media
|
||||
|
||||
Rules:
|
||||
- Always check if a material already has ready media of a given kind before regenerating; if so, skip.
|
||||
- Stop after issuing the planned tool calls; never invent material ids.
|
||||
- When done, emit a short text summary listing what was generated (media_id, status, error if any).</field>
|
||||
<field name="tool_ids" eval="[(6, 0, [
|
||||
ref('ai_tool_media_audio'),
|
||||
ref('ai_tool_media_image'),
|
||||
ref('ai_tool_media_video'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<!-- Default per-plan image budget. Adjust in System Parameters. -->
|
||||
<record id="ai_default_image_budget" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai_course.image_budget_per_plan</field>
|
||||
<field name="value">60</field>
|
||||
</record>
|
||||
|
||||
<!-- Feature flag: pipelines consult this before routing through AgentRuntime.
|
||||
Default "True" so the defaults-ship-working contract holds. -->
|
||||
<record id="ai_default_use_langgraph" model="ir.config_parameter">
|
||||
|
||||
@@ -66,6 +66,7 @@ TOOL_CATEGORIES = [
|
||||
("quality", "Quality & gating"),
|
||||
("scoring", "Scoring & grading"),
|
||||
("reference", "Reference lookup"),
|
||||
("media", "Media generation"),
|
||||
("other", "Other"),
|
||||
]
|
||||
|
||||
|
||||
@@ -9,3 +9,6 @@ from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9)
|
||||
from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1)
|
||||
from . import agent_tools # registry of tool handlers used by AgentRuntime
|
||||
from .agent_runtime import AgentRuntime # LangGraph-backed core agent runtime
|
||||
from . import provider_router # capability -> active-provider resolver
|
||||
from . import free_image # offline Pillow-based image placeholder
|
||||
from . import free_tts # gTTS + silent-MP3 audio fallbacks
|
||||
|
||||
@@ -78,6 +78,7 @@ class AgentState(TypedDict, total=False):
|
||||
retrieval: list[dict] # hits from the retrieval node (RAG)
|
||||
iterations: int # guard against runaway ReAct loops
|
||||
error: str # populated on fatal failure
|
||||
should_revise: bool # set by review node when a revise pass is queued
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -92,6 +93,13 @@ class AgentRuntime:
|
||||
# Factories
|
||||
# ------------------------------------------------------------------
|
||||
def __init__(self, env, agent, *, language: str | None = None):
|
||||
# INVARIANT: every AgentRuntime is per-request and constructs a
|
||||
# fresh OpenAIService, which reads ir.config_parameter on every
|
||||
# __init__. Combined with MediaService → provider_router (also
|
||||
# uncached), this guarantees that flipping a provider in the
|
||||
# admin UI takes effect on the very next request — no Odoo
|
||||
# restart, no cache invalidation. Don't introduce any
|
||||
# module-level or class-level provider caches here.
|
||||
self.env = env
|
||||
self.agent = agent
|
||||
self.language = language
|
||||
@@ -323,7 +331,21 @@ class AgentRuntime:
|
||||
return {**state, "messages": messages, "retrieval": items}
|
||||
|
||||
def _node_review(self, state: AgentState) -> AgentState:
|
||||
"""Run every configured quality tool against the LLM's output."""
|
||||
"""Run quality tools against the LLM output and (maybe) queue a revision.
|
||||
|
||||
We do all state mutations here — adding the critique message and
|
||||
bumping ``revisions_used`` — and only signal the router with a
|
||||
boolean ``should_revise``. LangGraph routing functions are
|
||||
treated as pure: any state changes made there are discarded, so
|
||||
keeping the router pure prevents an infinite revise loop where
|
||||
the counter never actually increments.
|
||||
"""
|
||||
# If the LLM step already errored out, don't waste a quality
|
||||
# check on the empty/garbage output and don't try to "revise" —
|
||||
# another LLM call would just hit the same permanent failure.
|
||||
if state.get("error"):
|
||||
return {**state, "quality_issues": [], "should_revise": False}
|
||||
|
||||
text = state.get("output_raw") or ""
|
||||
if isinstance(state.get("output"), dict):
|
||||
# Flatten the dict to text so the quality tools see something
|
||||
@@ -348,29 +370,38 @@ class AgentRuntime:
|
||||
})
|
||||
if res.get("ok") is False:
|
||||
issues.extend(res.get("issues") or [res.get("error") or key])
|
||||
return {**state, "quality_issues": issues}
|
||||
|
||||
revisions_used = state.get("revisions_used") or 0
|
||||
max_rev = max(0, int(self.agent.max_revisions or 0))
|
||||
if issues and revisions_used < max_rev:
|
||||
critique = (
|
||||
"Your previous draft was rejected for the following reasons:\n- "
|
||||
+ "\n- ".join(issues)
|
||||
+ "\n\nProduce an improved version that addresses every issue. "
|
||||
"Keep the same JSON schema if one was requested."
|
||||
)
|
||||
messages = list(state.get("messages") or []) + [
|
||||
{"role": "system", "content": critique}
|
||||
]
|
||||
return {
|
||||
**state,
|
||||
"messages": messages,
|
||||
"quality_issues": issues,
|
||||
"revisions_used": revisions_used + 1,
|
||||
"should_revise": True,
|
||||
}
|
||||
return {
|
||||
**state,
|
||||
"quality_issues": issues,
|
||||
"should_revise": False,
|
||||
}
|
||||
|
||||
def _route_after_review(self, state: AgentState) -> str:
|
||||
issues = state.get("quality_issues") or []
|
||||
if not issues:
|
||||
# Pure router: only inspect state, never mutate. The decision
|
||||
# was prepared in ``_node_review``.
|
||||
if state.get("error"):
|
||||
return "done"
|
||||
if (state.get("revisions_used") or 0) >= max(0, self.agent.max_revisions):
|
||||
return "done"
|
||||
# Queue up a revision: add a system message with the critique and
|
||||
# bump the counter. We return via "revise" which loops back to
|
||||
# the LLM node.
|
||||
critique = (
|
||||
"Your previous draft was rejected for the following reasons:\n- "
|
||||
+ "\n- ".join(issues)
|
||||
+ "\n\nProduce an improved version that addresses every issue. "
|
||||
"Keep the same JSON schema if one was requested."
|
||||
)
|
||||
messages = list(state.get("messages") or []) + [
|
||||
{"role": "system", "content": critique}
|
||||
]
|
||||
state["messages"] = messages
|
||||
state["revisions_used"] = (state.get("revisions_used") or 0) + 1
|
||||
return "revise"
|
||||
return "revise" if state.get("should_revise") else "done"
|
||||
|
||||
# ReAct / tool-calling -------------------------------------------------
|
||||
def _node_llm_tools(self, state: AgentState) -> AgentState:
|
||||
|
||||
@@ -88,20 +88,31 @@ def invoke(env, key: str, params: dict | None = None) -> dict:
|
||||
# --- Retrieval ----------------------------------------------------------------
|
||||
@register("resources.search")
|
||||
def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "",
|
||||
limit: int = 5, **_: Any) -> dict:
|
||||
limit: int = 5, plan_id: int | None = None,
|
||||
**_: Any) -> dict:
|
||||
"""Semantic search over the LMS resource library.
|
||||
|
||||
When ``plan_id`` is provided, retrieval is scoped to that plan's
|
||||
indexed reference sources only (uploaded PDFs, URLs, inline text).
|
||||
Without ``plan_id`` we search the global resource library.
|
||||
|
||||
Returns titles + short snippets so the agent can cite existing
|
||||
materials instead of inventing new ones every run.
|
||||
"""
|
||||
from odoo.addons.encoach_vector.services.embedding_service import (
|
||||
EmbeddingService, # noqa: F401
|
||||
EmbeddingService,
|
||||
)
|
||||
try:
|
||||
svc = EmbeddingService(env)
|
||||
# EmbeddingService.search is expected to filter by content_type;
|
||||
# we accept a skill filter from the agent but don't require it.
|
||||
results = svc.search(query or "", limit=int(limit or 5))
|
||||
if plan_id:
|
||||
results = svc.search(
|
||||
query or "",
|
||||
content_type="course_plan_source",
|
||||
entity_id=int(plan_id),
|
||||
limit=int(limit or 5),
|
||||
)
|
||||
else:
|
||||
results = svc.search(query or "", limit=int(limit or 5))
|
||||
except Exception as exc:
|
||||
_logger.debug("resource vector search unavailable: %s", exc)
|
||||
results = []
|
||||
@@ -114,7 +125,7 @@ def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "
|
||||
"snippet": (r.get("text") or "")[:400],
|
||||
"similarity": r.get("similarity"),
|
||||
})
|
||||
return {"query": query, "count": len(out), "items": out}
|
||||
return {"query": query, "plan_id": plan_id, "count": len(out), "items": out}
|
||||
|
||||
|
||||
@register("rubric.fetch")
|
||||
@@ -324,3 +335,84 @@ def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> di
|
||||
return svc.grade_speaking(rubric, transcript)
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
# --- Media generation tools ---------------------------------------------------
|
||||
#
|
||||
# These bridge an agent decision (e.g. "this listening lesson needs an MP3")
|
||||
# into the MediaService implementation. They are mutating tools — the seed
|
||||
# row in agents_defaults.xml has ``mutates=True`` so the runtime wraps them
|
||||
# in a savepoint.
|
||||
|
||||
@register("media.synthesize_audio")
|
||||
def _media_synthesize_audio(env, material_id: int, voice: str = "",
|
||||
language: str = "en-GB",
|
||||
gender: str = "female",
|
||||
provider: str = "polly", **_: Any) -> dict:
|
||||
Material = env["encoach.course.plan.material"].sudo() \
|
||||
if "encoach.course.plan.material" in env else None
|
||||
if Material is None:
|
||||
return {"error": "course_plan_material_model_missing"}
|
||||
rec = Material.browse(int(material_id))
|
||||
if not rec.exists():
|
||||
return {"error": "material_not_found"}
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
svc = MediaService(env)
|
||||
media = svc.synthesize_audio(
|
||||
rec, voice=voice or None, language=language or "en-GB",
|
||||
gender=gender or "female", provider=provider or "polly",
|
||||
)
|
||||
return {
|
||||
"media_id": media.id,
|
||||
"status": media.status,
|
||||
"download_url": media.download_url,
|
||||
"error": media.error or None,
|
||||
}
|
||||
|
||||
|
||||
@register("media.generate_image")
|
||||
def _media_generate_image(env, material_id: int, prompt: str = "",
|
||||
size: str = "1024x1024",
|
||||
style: str = "natural",
|
||||
quality: str = "standard", **_: Any) -> dict:
|
||||
Material = env["encoach.course.plan.material"].sudo() \
|
||||
if "encoach.course.plan.material" in env else None
|
||||
if Material is None:
|
||||
return {"error": "course_plan_material_model_missing"}
|
||||
rec = Material.browse(int(material_id))
|
||||
if not rec.exists():
|
||||
return {"error": "material_not_found"}
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
svc = MediaService(env)
|
||||
media = svc.generate_image(
|
||||
rec, custom_prompt=prompt or None,
|
||||
size=size or "1024x1024",
|
||||
style=style or "natural",
|
||||
quality=quality or "standard",
|
||||
)
|
||||
return {
|
||||
"media_id": media.id,
|
||||
"status": media.status,
|
||||
"download_url": media.download_url,
|
||||
"error": media.error or None,
|
||||
}
|
||||
|
||||
|
||||
@register("media.compose_video")
|
||||
def _media_compose_video(env, material_id: int, **_: Any) -> dict:
|
||||
Material = env["encoach.course.plan.material"].sudo() \
|
||||
if "encoach.course.plan.material" in env else None
|
||||
if Material is None:
|
||||
return {"error": "course_plan_material_model_missing"}
|
||||
rec = Material.browse(int(material_id))
|
||||
if not rec.exists():
|
||||
return {"error": "material_not_found"}
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
svc = MediaService(env)
|
||||
media = svc.compose_video(rec)
|
||||
return {
|
||||
"media_id": media.id,
|
||||
"status": media.status,
|
||||
"download_url": media.download_url,
|
||||
"error": media.error or None,
|
||||
}
|
||||
|
||||
150
backend/custom_addons/encoach_ai/services/free_image.py
Normal file
150
backend/custom_addons/encoach_ai/services/free_image.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Offline image placeholder generator using Pillow.
|
||||
|
||||
Used as a free fallback when DALL-E (or any paid image API) is missing
|
||||
credentials, returns a billing/quota error, or is otherwise unavailable.
|
||||
The resulting PNG is a clean education-themed gradient card with the
|
||||
title overlaid — good enough to keep the LMS UI populated until a real
|
||||
image is generated.
|
||||
|
||||
Pillow is the only runtime dependency (already a transitive dep of Odoo
|
||||
through ``reportlab`` on most installs). If Pillow is not importable we
|
||||
raise so the caller knows to skip this provider and try the next one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import random
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError: # pragma: no cover — Pillow is a soft dep
|
||||
Image = None
|
||||
ImageDraw = None
|
||||
ImageFont = None
|
||||
|
||||
|
||||
# Soft education palettes (top-left, bottom-right gradient pairs)
|
||||
_PALETTES = [
|
||||
((59, 130, 246), (147, 197, 253)), # blue
|
||||
((16, 185, 129), (110, 231, 183)), # emerald
|
||||
((245, 158, 11), (252, 211, 77)), # amber
|
||||
((139, 92, 246), (196, 181, 253)), # violet
|
||||
((236, 72, 153), (249, 168, 212)), # pink
|
||||
((20, 184, 166), (153, 246, 228)), # teal
|
||||
((99, 102, 241), (165, 180, 252)), # indigo
|
||||
((220, 38, 38), (252, 165, 165)), # rose
|
||||
]
|
||||
|
||||
|
||||
def _parse_size(size):
|
||||
try:
|
||||
w, h = (int(p) for p in str(size).lower().split('x'))
|
||||
except Exception:
|
||||
return 1024, 1024
|
||||
return max(64, min(2048, w)), max(64, min(2048, h))
|
||||
|
||||
|
||||
def _wrap(draw, text, font, max_width, max_lines=6):
|
||||
words = (text or '').split()
|
||||
lines, current = [], ''
|
||||
for word in words:
|
||||
candidate = (current + ' ' + word).strip()
|
||||
if draw.textlength(candidate, font=font) <= max_width:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if len(lines) >= max_lines:
|
||||
return lines
|
||||
if current and len(lines) < max_lines:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
def _load_font(preferred, size):
|
||||
"""Try a list of fonts; fall back to the bundled default at any size."""
|
||||
for name in preferred:
|
||||
try:
|
||||
return ImageFont.truetype(name, size=size)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def render_placeholder(title, *, subtitle=None, size='1024x1024', seed=None):
|
||||
"""Return PNG bytes for a placeholder card.
|
||||
|
||||
Args:
|
||||
title: Main title text rendered large and centred.
|
||||
subtitle: Optional smaller line below the title (e.g. CEFR level,
|
||||
week label) — pass ``None`` to skip.
|
||||
size: ``"WIDTHxHEIGHT"`` string, e.g. ``"1024x1024"``.
|
||||
seed: Optional seed for palette selection so the same title yields
|
||||
the same gradient repeatably.
|
||||
"""
|
||||
if Image is None:
|
||||
raise RuntimeError(
|
||||
'Pillow not installed — pip install Pillow to enable the free '
|
||||
'image fallback.'
|
||||
)
|
||||
w, h = _parse_size(size)
|
||||
rng = random.Random(seed if seed is not None else (title or '').lower())
|
||||
top, bottom = rng.choice(_PALETTES)
|
||||
|
||||
img = Image.new('RGB', (w, h), top)
|
||||
px = img.load()
|
||||
for y in range(h):
|
||||
t = y / max(1, h - 1)
|
||||
r = int(top[0] * (1 - t) + bottom[0] * t)
|
||||
g = int(top[1] * (1 - t) + bottom[1] * t)
|
||||
b = int(top[2] * (1 - t) + bottom[2] * t)
|
||||
for x in range(w):
|
||||
px[x, y] = (r, g, b)
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
title_font = _load_font(
|
||||
['DejaVuSans-Bold.ttf', 'Arial Bold.ttf', 'arial.ttf'],
|
||||
int(h * 0.07),
|
||||
)
|
||||
sub_font = _load_font(
|
||||
['DejaVuSans.ttf', 'Arial.ttf', 'arial.ttf'],
|
||||
int(h * 0.035),
|
||||
)
|
||||
|
||||
margin = int(w * 0.08)
|
||||
max_text_w = w - 2 * margin
|
||||
title_lines = _wrap(draw, title or 'Untitled', title_font, max_text_w)
|
||||
line_h = int(h * 0.085)
|
||||
total_h = line_h * len(title_lines)
|
||||
y = (h - total_h) // 2
|
||||
for line in title_lines:
|
||||
line_w = draw.textlength(line, font=title_font)
|
||||
x = (w - line_w) // 2
|
||||
draw.text((x + 2, y + 2), line, font=title_font, fill=(0, 0, 0))
|
||||
draw.text((x, y), line, font=title_font, fill=(255, 255, 255))
|
||||
y += line_h
|
||||
|
||||
if subtitle:
|
||||
sub_w = draw.textlength(subtitle, font=sub_font)
|
||||
sx = (w - sub_w) // 2
|
||||
sy = y + int(h * 0.02)
|
||||
draw.text((sx + 1, sy + 1), subtitle, font=sub_font, fill=(0, 0, 0))
|
||||
draw.text((sx, sy), subtitle, font=sub_font, fill=(255, 255, 255))
|
||||
|
||||
# Subtle EnCoach watermark badge so generated assets are easy to spot
|
||||
badge_font = _load_font(['DejaVuSans.ttf', 'Arial.ttf'], int(h * 0.022))
|
||||
badge = 'EnCoach · placeholder'
|
||||
bw = draw.textlength(badge, font=badge_font)
|
||||
draw.text(
|
||||
(w - margin - bw, h - margin),
|
||||
badge, font=badge_font, fill=(255, 255, 255),
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG', optimize=True)
|
||||
return buf.getvalue()
|
||||
128
backend/custom_addons/encoach_ai/services/free_tts.py
Normal file
128
backend/custom_addons/encoach_ai/services/free_tts.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Free / offline text-to-speech fallbacks.
|
||||
|
||||
Two providers, ordered most-useful-first:
|
||||
|
||||
1. ``gtts`` — Google Translate TTS. Free and surprisingly natural, but
|
||||
requires outbound network access. We try this first when
|
||||
the paid provider is exhausted.
|
||||
|
||||
2. ``silent`` — A pre-encoded silent MP3 (~1 second). Used as a last
|
||||
resort so that downstream consumers (notably the video
|
||||
composer) still receive valid audio bytes and don't crash.
|
||||
|
||||
The shape of the return dict matches what ``PollyService.synthesize``
|
||||
returns (``audio``, ``content_type``, ``voice``, ``characters``) so
|
||||
callers can swap providers transparently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try: # pragma: no cover — gTTS is a soft dep
|
||||
from gtts import gTTS
|
||||
except ImportError:
|
||||
gTTS = None
|
||||
|
||||
|
||||
def _build_silent_wav(duration_seconds: float = 1.0,
|
||||
sample_rate: int = 8000) -> bytes:
|
||||
"""Construct a valid PCM WAV byte string with all-zero samples.
|
||||
|
||||
WAV is trivially constructable from primitives so we can produce it
|
||||
without any external library. ffmpeg accepts WAV as readily as MP3,
|
||||
so the rest of the pipeline is unaffected by the format choice.
|
||||
"""
|
||||
n_samples = max(1, int(duration_seconds * sample_rate))
|
||||
samples = b'\x00\x00' * n_samples # 16-bit mono silence
|
||||
data_size = len(samples)
|
||||
fmt_chunk = (
|
||||
b'fmt '
|
||||
+ struct.pack('<I', 16) # PCM fmt chunk size
|
||||
+ struct.pack('<H', 1) # PCM
|
||||
+ struct.pack('<H', 1) # mono
|
||||
+ struct.pack('<I', sample_rate)
|
||||
+ struct.pack('<I', sample_rate * 2) # byte rate
|
||||
+ struct.pack('<H', 2) # block align
|
||||
+ struct.pack('<H', 16) # bits per sample
|
||||
)
|
||||
data_chunk = b'data' + struct.pack('<I', data_size) + samples
|
||||
return (
|
||||
b'RIFF'
|
||||
+ struct.pack('<I', 36 + data_size)
|
||||
+ b'WAVE'
|
||||
+ fmt_chunk
|
||||
+ data_chunk
|
||||
)
|
||||
|
||||
|
||||
# Map our internal language codes to (gTTS lang, gTTS tld) tuples. The
|
||||
# tld controls accent (co.uk vs com vs com.au) so picking carefully here
|
||||
# gives the listening exam a more authentic accent.
|
||||
_GTTS_LANG_MAP = {
|
||||
'en-GB': ('en', 'co.uk'),
|
||||
'en-US': ('en', 'com'),
|
||||
'en-AU': ('en', 'com.au'),
|
||||
'en-IN': ('en', 'co.in'),
|
||||
'en': ('en', 'co.uk'),
|
||||
'ar': ('ar', 'com'),
|
||||
'ar-EG': ('ar', 'com'),
|
||||
'ar-SA': ('ar', 'com'),
|
||||
'fr': ('fr', 'fr'),
|
||||
'fr-FR': ('fr', 'fr'),
|
||||
'es': ('es', 'es'),
|
||||
'de': ('de', 'de'),
|
||||
'tr': ('tr', 'com'),
|
||||
'fa': ('fa', 'com'),
|
||||
'ur': ('ur', 'com'),
|
||||
'hi': ('hi', 'co.in'),
|
||||
'zh': ('zh-CN', 'com'),
|
||||
'ja': ('ja', 'com'),
|
||||
}
|
||||
|
||||
|
||||
def synthesize_with_gtts(text, *, language='en-GB'):
|
||||
"""Synthesize ``text`` to MP3 bytes using gTTS.
|
||||
|
||||
Raises ``RuntimeError`` if gTTS is not installed; the caller is
|
||||
expected to catch and try the next provider in the chain.
|
||||
"""
|
||||
if gTTS is None:
|
||||
raise RuntimeError(
|
||||
'gTTS not installed — pip install gTTS to enable the free '
|
||||
'audio fallback.'
|
||||
)
|
||||
short = (text or '')[:4500]
|
||||
if not short.strip():
|
||||
return synthesize_silent()
|
||||
lang, tld = _GTTS_LANG_MAP.get(language, ('en', 'co.uk'))
|
||||
buf = io.BytesIO()
|
||||
tts = gTTS(text=short, lang=lang, tld=tld, slow=False)
|
||||
tts.write_to_fp(buf)
|
||||
return {
|
||||
'audio': buf.getvalue(),
|
||||
'content_type': 'audio/mpeg',
|
||||
'voice': f'gtts-{lang}-{tld}',
|
||||
'characters': len(short),
|
||||
}
|
||||
|
||||
|
||||
def synthesize_silent(duration_seconds=1):
|
||||
"""Return a minimal valid silent audio stub.
|
||||
|
||||
Returns a PCM WAV (which ffmpeg accepts identically to MP3) of the
|
||||
requested duration. Used when even gTTS is unreachable and we just
|
||||
need *some* valid audio so the video composer doesn't fail and the
|
||||
media row can still be marked ``ready``.
|
||||
"""
|
||||
payload = _build_silent_wav(duration_seconds=duration_seconds)
|
||||
return {
|
||||
'audio': payload,
|
||||
'content_type': 'audio/wav',
|
||||
'voice': 'silent-stub',
|
||||
'characters': 0,
|
||||
}
|
||||
@@ -64,7 +64,18 @@ class OpenAIService:
|
||||
import os
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if _openai_mod and api_key:
|
||||
self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout)
|
||||
# The SDK retries internally up to 2 times by default with exponential
|
||||
# backoff, but we already do that ourselves in `_retry_with_backoff`.
|
||||
# Stacking both meant a single quota error could trigger 9+ retries
|
||||
# over several minutes before the controller could return — leaving
|
||||
# the frontend's `Generate plan` button hanging. We disable the
|
||||
# SDK's retries and let our own loop (which knows about
|
||||
# insufficient_quota) be the single source of truth.
|
||||
self.client = _openai_mod.OpenAI(
|
||||
api_key=api_key,
|
||||
timeout=self.request_timeout,
|
||||
max_retries=0,
|
||||
)
|
||||
else:
|
||||
self.client = None
|
||||
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
|
||||
@@ -114,6 +125,16 @@ class OpenAIService:
|
||||
return messages
|
||||
|
||||
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
|
||||
# Skip writing if the request transaction has already been
|
||||
# aborted/rolled back (typically when an upstream caller caught
|
||||
# the AI exception, re-raised, and the surrounding `try/except`
|
||||
# in our route handler is about to return 500). Trying to insert
|
||||
# in that state raises `psycopg2.InterfaceError: cursor already
|
||||
# closed` and pollutes the log with a misleading second stack
|
||||
# trace that hides the real upstream failure.
|
||||
cr = getattr(self.env, "cr", None)
|
||||
if cr is None or getattr(cr, "closed", False):
|
||||
return
|
||||
try:
|
||||
self.env["encoach.ai.log"].sudo().create({
|
||||
"service": "openai",
|
||||
@@ -128,15 +149,41 @@ class OpenAIService:
|
||||
"input_preview": (inp or "")[:500],
|
||||
"output_preview": (out or "")[:500],
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning("Failed to log AI call", exc_info=True)
|
||||
except Exception as exc:
|
||||
# Most common case is psycopg2.InterfaceError when the txn
|
||||
# has already been rolled back by a higher-level handler.
|
||||
# Don't include `exc_info=True` for that one — it's noise.
|
||||
err_str = str(exc).lower()
|
||||
if "cursor already closed" in err_str or "current transaction is aborted" in err_str:
|
||||
_logger.debug("Skipping AI log write — txn already aborted (%s)", action)
|
||||
else:
|
||||
_logger.warning("Failed to log AI call", exc_info=True)
|
||||
|
||||
def _check_enabled(self):
|
||||
if not self.enabled:
|
||||
raise RuntimeError("AI is disabled — enable in Settings > AI Configuration")
|
||||
|
||||
# Errors that will never resolve by retrying. These are user / billing
|
||||
# / configuration conditions: retrying just wastes wall-clock time and
|
||||
# leaves the frontend hanging on the wizard "Finish" button.
|
||||
_NON_RETRYABLE_MARKERS = (
|
||||
"insufficient_quota",
|
||||
"invalid_api_key",
|
||||
"incorrect_api_key",
|
||||
"account_deactivated",
|
||||
"billing_hard_limit_reached",
|
||||
"model_not_found",
|
||||
"context_length_exceeded",
|
||||
)
|
||||
|
||||
def _retry_with_backoff(self, fn, action, model):
|
||||
"""Execute fn with exponential backoff retries."""
|
||||
"""Execute ``fn`` with exponential backoff retries.
|
||||
|
||||
Permanent failures (quota exhausted, bad API key, etc.) are raised
|
||||
on the first attempt; transient ones (true rate-limit, 5xx) are
|
||||
retried up to ``self.max_retries``. The OpenAI SDK is configured
|
||||
with ``max_retries=0`` so this loop is the only retry layer.
|
||||
"""
|
||||
last_exc = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
@@ -144,6 +191,12 @@ class OpenAIService:
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
err_str = str(exc).lower()
|
||||
if any(m in err_str for m in self._NON_RETRYABLE_MARKERS):
|
||||
_logger.warning(
|
||||
"AI permanent failure for %s (no retry): %s",
|
||||
action, exc,
|
||||
)
|
||||
raise
|
||||
is_rate_limit = "rate" in err_str or "429" in err_str
|
||||
is_server_error = "500" in err_str or "502" in err_str or "503" in err_str
|
||||
if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1:
|
||||
@@ -213,6 +266,60 @@ class OpenAIService:
|
||||
"""Use the fast/cheap model for classification, tagging, simple tasks."""
|
||||
return self.chat(messages, model=self.fast_model, **kwargs)
|
||||
|
||||
def generate_image(self, prompt, *, size="1024x1024", style="natural",
|
||||
quality="standard", model=None):
|
||||
"""Generate an image with DALL-E 3 and return raw PNG bytes.
|
||||
|
||||
Returns:
|
||||
dict {"image": bytes, "revised_prompt": str, "model": str,
|
||||
"size": str, "style": str}.
|
||||
|
||||
Raises ``RuntimeError`` if OpenAI is not configured. Network /
|
||||
moderation failures bubble up as the SDK's exceptions; callers
|
||||
should catch and record them.
|
||||
"""
|
||||
self._check_enabled()
|
||||
if not self.client:
|
||||
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||
image_model = model or self._get_param(
|
||||
"encoach_ai.openai_image_model", "dall-e-3",
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = self.client.images.generate(
|
||||
model=image_model,
|
||||
prompt=prompt or "",
|
||||
n=1,
|
||||
size=size,
|
||||
style=style,
|
||||
quality=quality,
|
||||
response_format="b64_json",
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
data = resp.data[0]
|
||||
import base64 as _b64
|
||||
img_bytes = _b64.b64decode(data.b64_json)
|
||||
self._log(
|
||||
"generate_image", image_model, None,
|
||||
int((time.time() - t0) * 1000),
|
||||
inp=(prompt or "")[:500],
|
||||
out=f"{len(img_bytes)}B {size} {style}",
|
||||
)
|
||||
return {
|
||||
"image": img_bytes,
|
||||
"revised_prompt": getattr(data, "revised_prompt", "") or "",
|
||||
"model": image_model,
|
||||
"size": size,
|
||||
"style": style,
|
||||
}
|
||||
except Exception as exc:
|
||||
self._log(
|
||||
"generate_image", image_model, None,
|
||||
int((time.time() - t0) * 1000),
|
||||
status="error", error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
def grade_writing(self, rubric, task_text, response_text):
|
||||
"""Grade a writing response using GPT with a rubric."""
|
||||
messages = [
|
||||
|
||||
187
backend/custom_addons/encoach_ai/services/provider_router.py
Normal file
187
backend/custom_addons/encoach_ai/services/provider_router.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Resolve the active AI provider per capability.
|
||||
|
||||
Settings are read fresh from ``ir.config_parameter`` on every call — there
|
||||
is intentionally NO module-level cache so flipping a provider in the admin
|
||||
UI takes effect on the next request without restarting Odoo.
|
||||
|
||||
Public surface:
|
||||
|
||||
* :func:`get_active_provider(env, capability)` — returns the provider key
|
||||
configured for ``capability`` (one of ``text|image|audio|video``).
|
||||
* :func:`classify_provider_error(exc)` — turns an arbitrary exception from
|
||||
any third-party SDK into one of ``quota|auth|network|other`` so callers
|
||||
can decide whether to fall back automatically.
|
||||
* :class:`ProviderQuotaError`, :class:`ProviderAuthError` — typed errors
|
||||
that callers may raise when they want a strongly-typed signal.
|
||||
|
||||
The capability tables also enumerate the **allowed free providers** per
|
||||
capability — these are the ones the fallback chain uses when the paid
|
||||
provider returns a quota or auth error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capabilities & provider names
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ``auto`` means "pick the first paid provider that's configured, else fall
|
||||
# back to the first free provider that works". Admins who want to *force* a
|
||||
# specific provider should pick its name explicitly in the UI.
|
||||
|
||||
CAPABILITIES = {
|
||||
'text': {
|
||||
'param': 'encoach.ai.text_provider',
|
||||
'default': 'openai',
|
||||
'paid': ['openai'],
|
||||
'free': ['mock'],
|
||||
},
|
||||
'image': {
|
||||
'param': 'encoach.ai.image_provider',
|
||||
'default': 'auto',
|
||||
'paid': ['openai'],
|
||||
'free': ['pillow', 'unsplash', 'mock'],
|
||||
},
|
||||
'audio': {
|
||||
'param': 'encoach.ai.audio_provider',
|
||||
'default': 'auto',
|
||||
'paid': ['polly', 'elevenlabs'],
|
||||
'free': ['gtts', 'silent'],
|
||||
},
|
||||
'video': {
|
||||
'param': 'encoach.ai.video_provider',
|
||||
'default': 'auto',
|
||||
'paid': [],
|
||||
'free': ['ffmpeg', 'static'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typed errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProviderQuotaError(RuntimeError):
|
||||
"""Raised when a provider returns a billing/quota error.
|
||||
|
||||
Examples include OpenAI ``insufficient_quota``, AWS Polly
|
||||
``ThrottlingException``, ElevenLabs character-limit rejection, and any
|
||||
HTTP 402/429. Callers should treat this as a soft failure and try the
|
||||
next provider in the fallback chain.
|
||||
"""
|
||||
|
||||
|
||||
class ProviderAuthError(RuntimeError):
|
||||
"""Raised when a provider refuses authentication (missing/invalid key)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_active_provider(env, capability):
|
||||
"""Read the currently-active provider key for ``capability``.
|
||||
|
||||
Always reads from ``ir.config_parameter`` — never cached.
|
||||
"""
|
||||
cap = CAPABILITIES[capability]
|
||||
return env['ir.config_parameter'].sudo().get_param(
|
||||
cap['param'], cap['default'],
|
||||
) or cap['default']
|
||||
|
||||
|
||||
def get_paid_provider_keys(env, capability):
|
||||
"""Return the list of paid providers for the capability that have an
|
||||
API key configured. Used to decide whether to skip straight to free
|
||||
fallbacks when ``auto`` is selected but no paid keys exist.
|
||||
"""
|
||||
Param = env['ir.config_parameter'].sudo()
|
||||
available = []
|
||||
for provider in CAPABILITIES[capability]['paid']:
|
||||
if _provider_has_credentials(Param, provider):
|
||||
available.append(provider)
|
||||
return available
|
||||
|
||||
|
||||
def _provider_has_credentials(Param, provider):
|
||||
if provider == 'openai':
|
||||
return bool(Param.get_param('encoach_ai.openai_api_key'))
|
||||
if provider == 'polly':
|
||||
return bool(Param.get_param('encoach_ai.aws_access_key')) and bool(
|
||||
Param.get_param('encoach_ai.aws_secret_key')
|
||||
)
|
||||
if provider == 'elevenlabs':
|
||||
return bool(Param.get_param('encoach_ai.elevenlabs_api_key'))
|
||||
return False
|
||||
|
||||
|
||||
def resolve_chain(env, capability, *, requested=None):
|
||||
"""Return an ordered list of providers to try for ``capability``.
|
||||
|
||||
Args:
|
||||
capability: ``'text' | 'image' | 'audio' | 'video'``.
|
||||
requested: optional explicit provider override (e.g. body param).
|
||||
When supplied it goes to the front of the chain.
|
||||
"""
|
||||
cap = CAPABILITIES[capability]
|
||||
chain = []
|
||||
selected = (requested or get_active_provider(env, capability) or '').strip()
|
||||
|
||||
if selected and selected != 'auto':
|
||||
chain.append(selected)
|
||||
|
||||
# Paid providers with credentials, then free fallbacks
|
||||
if selected == 'auto' or not selected:
|
||||
for p in get_paid_provider_keys(env, capability):
|
||||
if p not in chain:
|
||||
chain.append(p)
|
||||
for p in cap['free']:
|
||||
if p not in chain:
|
||||
chain.append(p)
|
||||
return chain
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_QUOTA_TOKENS = (
|
||||
'insufficient_quota', 'quota', 'billing',
|
||||
'429', '402',
|
||||
'rate limit', 'rate_limit', 'rate-limit',
|
||||
'throttlingexception', 'thresholdexceeded',
|
||||
'limit_exceeded', 'character limit', 'too many requests',
|
||||
)
|
||||
_AUTH_TOKENS = (
|
||||
'invalid_api_key', 'incorrect api key', 'authentication',
|
||||
'unauthorized', 'access denied', '401', '403',
|
||||
'authenticationerror', 'permissiondenied', 'invalid api key',
|
||||
'missing api key',
|
||||
)
|
||||
|
||||
|
||||
def classify_provider_error(exc):
|
||||
"""Map any provider exception to one of: ``quota|auth|network|other``."""
|
||||
msg = (str(exc) or '').lower()
|
||||
name = type(exc).__name__.lower()
|
||||
blob = msg + ' ' + name
|
||||
if any(t in blob for t in _QUOTA_TOKENS):
|
||||
return 'quota'
|
||||
if any(t in blob for t in _AUTH_TOKENS):
|
||||
return 'auth'
|
||||
if 'timeout' in blob or 'connection' in blob or 'network' in msg:
|
||||
return 'network'
|
||||
return 'other'
|
||||
|
||||
|
||||
def should_fallback(exc):
|
||||
"""Whether the caller should try the next provider in the chain."""
|
||||
return classify_provider_error(exc) in ('quota', 'auth', 'network')
|
||||
@@ -6,12 +6,15 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
|
||||
decorator and returns JSON.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required,
|
||||
validate_token,
|
||||
_json_response,
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
@@ -20,6 +23,10 @@ from odoo.addons.encoach_api.controllers.base import (
|
||||
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
|
||||
CoursePlanPipeline,
|
||||
)
|
||||
from odoo.addons.encoach_ai_course.services.deliverables import (
|
||||
compute_deliverables,
|
||||
)
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,7 +44,65 @@ def _request_language():
|
||||
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
|
||||
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for current JWT user."""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
return entity_ids, is_super
|
||||
|
||||
|
||||
def _default_entity_id_from_scope():
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if entity_ids:
|
||||
return entity_ids[0]
|
||||
if is_super:
|
||||
return False
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
|
||||
|
||||
def _ensure_entity_access(entity_id):
|
||||
if not entity_id:
|
||||
raise PermissionError('entity_id is required')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return int(entity_id)
|
||||
if not entity_ids:
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
if int(entity_id) not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
return int(entity_id)
|
||||
|
||||
|
||||
class CoursePlanController(http.Controller):
|
||||
def _plan_domain(self, extra=None):
|
||||
domain = list(extra or [])
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not entity_ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [('entity_id', 'in', entity_ids)]
|
||||
|
||||
def _assert_plan_access(self, plan):
|
||||
if not plan or not plan.exists():
|
||||
raise ValueError('Plan not found')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return
|
||||
if not plan.entity_id or plan.entity_id.id not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
|
||||
def _get_plan_scoped(self, plan_id):
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
self._assert_plan_access(plan)
|
||||
return plan
|
||||
|
||||
def _assert_material_access(self, material):
|
||||
if not material or not material.exists():
|
||||
raise ValueError('Material not found')
|
||||
self._assert_plan_access(material.plan_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan
|
||||
# ------------------------------------------------------------------
|
||||
@@ -49,15 +114,21 @@ class CoursePlanController(http.Controller):
|
||||
body = _get_json_body()
|
||||
if not (body.get('title') or '').strip():
|
||||
return _error_response('title is required', 400)
|
||||
if body.get('entity_id'):
|
||||
entity_id = _ensure_entity_access(int(body['entity_id']))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
plan = pipeline.generate_plan(body)
|
||||
if entity_id:
|
||||
plan.sudo().write({'entity_id': entity_id})
|
||||
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan
|
||||
@@ -68,10 +139,12 @@ class CoursePlanController(http.Controller):
|
||||
def list_plans(self, **kw):
|
||||
try:
|
||||
params = request.httprequest.args
|
||||
domain = []
|
||||
domain = self._plan_domain([])
|
||||
search = (params.get('search') or '').strip()
|
||||
if search:
|
||||
domain.append(('name', 'ilike', search))
|
||||
if params.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id')))))
|
||||
|
||||
Plan = request.env['encoach.course.plan'].sudo()
|
||||
offset, limit, page = _paginate({
|
||||
@@ -89,7 +162,7 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>
|
||||
@@ -99,15 +172,15 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def get_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.get failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/ai/course-plan/<id>
|
||||
@@ -117,14 +190,14 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def delete_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
plan.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -134,6 +207,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def generate_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
@@ -146,7 +220,7 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -156,6 +230,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
week = request.env['encoach.course.plan.week'].sudo().search([
|
||||
('plan_id', '=', int(plan_id)),
|
||||
('week_number', '=', int(week_number)),
|
||||
@@ -168,4 +243,611 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_week_materials failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE A — Reference sources (RAG grounding)
|
||||
# ==================================================================
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_sources(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [s.to_api_dict() for s in plan.source_ids],
|
||||
'count': len(plan.source_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_sources failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_source(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
ct = request.httprequest.content_type or ''
|
||||
files = request.httprequest.files
|
||||
uploaded = files.get('file')
|
||||
if 'application/json' in ct:
|
||||
body = _get_json_body() or {}
|
||||
else:
|
||||
body = {**kw}
|
||||
|
||||
kind = (body.get('kind') or '').strip() or (
|
||||
'file' if uploaded else
|
||||
('url' if (body.get('url') or '').strip() else 'text')
|
||||
)
|
||||
name = (body.get('name') or '').strip()
|
||||
auto_index = body.get('auto_index')
|
||||
if isinstance(auto_index, str):
|
||||
auto_index = auto_index.lower() not in ('0', 'false', 'no', 'off')
|
||||
elif auto_index is None:
|
||||
auto_index = True
|
||||
vals = {
|
||||
'plan_id': plan.id,
|
||||
'kind': kind,
|
||||
'auto_index': bool(auto_index),
|
||||
}
|
||||
if kind == 'file':
|
||||
if not uploaded:
|
||||
return _error_response('No file uploaded', 400)
|
||||
payload = uploaded.read()
|
||||
vals['file'] = base64.b64encode(payload)
|
||||
vals['file_name'] = uploaded.filename or 'source'
|
||||
vals['mime_type'] = uploaded.mimetype or ''
|
||||
vals['name'] = name or uploaded.filename or 'source'
|
||||
elif kind == 'url':
|
||||
url = (body.get('url') or '').strip()
|
||||
if not url:
|
||||
return _error_response('URL is required', 400)
|
||||
vals['url'] = url
|
||||
vals['name'] = name or url
|
||||
else:
|
||||
text = (body.get('inline_text') or body.get('text') or '').strip()
|
||||
if not text:
|
||||
return _error_response('Inline text is required', 400)
|
||||
vals['inline_text'] = text
|
||||
vals['name'] = name or 'Inline text'
|
||||
|
||||
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_source failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<plan_id>/sources/from-resources
|
||||
# ------------------------------------------------------------------
|
||||
# Attach one or more existing library resources (``encoach.resource``,
|
||||
# the items shown under /admin/resources) as RAG sources for a plan.
|
||||
#
|
||||
# Body shape: ``{ "resource_ids": [<int>, ...] }``. We dedupe against
|
||||
# already-linked resources so re-clicking "Attach" is a no-op rather
|
||||
# than producing duplicate index entries. Each new row auto-indexes
|
||||
# via the ``encoach.course.plan.source`` create() hook.
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/from-resources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def attach_library_resources(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
raw_ids = body.get('resource_ids') or body.get('ids') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return _error_response('resource_ids must be a list', 400)
|
||||
try:
|
||||
resource_ids = [int(x) for x in raw_ids if x is not None]
|
||||
except (TypeError, ValueError):
|
||||
return _error_response('resource_ids must contain integers', 400)
|
||||
if not resource_ids:
|
||||
return _error_response('No resource ids provided', 400)
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
Source = request.env['encoach.course.plan.source'].sudo()
|
||||
|
||||
already_linked = set(
|
||||
plan.source_ids.filtered('resource_id').mapped('resource_id.id')
|
||||
)
|
||||
attached, skipped, missing = [], [], []
|
||||
for rid in resource_ids:
|
||||
if rid in already_linked:
|
||||
skipped.append(rid)
|
||||
continue
|
||||
res = Resource.browse(rid)
|
||||
if not res.exists():
|
||||
missing.append(rid)
|
||||
continue
|
||||
rec = Source.create({
|
||||
'plan_id': plan.id,
|
||||
'kind': 'resource',
|
||||
'resource_id': res.id,
|
||||
'name': res.name or f'Resource #{res.id}',
|
||||
'file_name': res.name or '',
|
||||
'mime_type': '',
|
||||
})
|
||||
attached.append(rec.to_api_dict())
|
||||
|
||||
return _json_response({
|
||||
'attached': attached,
|
||||
'skipped_existing': skipped,
|
||||
'missing': missing,
|
||||
'count': len(attached),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.attach_library_resources failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reindex_source(self, plan_id, source_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||
return _error_response('Source not found', 404)
|
||||
rec.action_index()
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.reindex_source failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_source(self, plan_id, source_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||
return _error_response('Source not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_source failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE B — Deliverables preview / progress
|
||||
# ==================================================================
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_deliverables(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response(compute_deliverables(plan))
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.deliverables failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE C — Multimedia generation per material
|
||||
# ==================================================================
|
||||
|
||||
def _resolve_material(self, material_id):
|
||||
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
|
||||
if not rec.exists():
|
||||
return None
|
||||
self._assert_material_access(rec)
|
||||
return rec
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>',
|
||||
type='http', auth='none', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def update_material(self, material_id, **kw):
|
||||
"""Edit generated material metadata/content without regenerating."""
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
vals = {}
|
||||
if 'title' in body:
|
||||
new_title = (body.get('title') or '').strip()
|
||||
if not new_title:
|
||||
return _error_response('title cannot be empty', 400)
|
||||
vals['title'] = new_title
|
||||
if 'summary' in body:
|
||||
vals['summary'] = (body.get('summary') or '').strip()
|
||||
if 'is_static' in body:
|
||||
vals['is_static'] = bool(body.get('is_static'))
|
||||
if 'share_date' in body:
|
||||
vals['share_date'] = body.get('share_date') or False
|
||||
if 'body' in body:
|
||||
vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False)
|
||||
if 'body_text' in body:
|
||||
body_text = (body.get('body_text') or '').strip()
|
||||
vals['body_text'] = body_text
|
||||
if 'body' not in body and body_text:
|
||||
# Keep non-technical editing simple: if only plain text is
|
||||
# provided, mirror it into a minimal JSON structure.
|
||||
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
|
||||
if vals:
|
||||
material.sudo().write(vals)
|
||||
return _json_response({'data': material.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.update_material failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def gen_audio(self, material_id, **kw):
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
svc = MediaService(request.env)
|
||||
media = svc.synthesize_audio(
|
||||
material,
|
||||
voice=body.get('voice'),
|
||||
language=body.get('language') or 'en-GB',
|
||||
gender=body.get('gender') or 'female',
|
||||
provider=body.get('provider') or 'auto',
|
||||
)
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_audio failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def gen_image(self, material_id, **kw):
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
svc = MediaService(request.env)
|
||||
media = svc.generate_image(
|
||||
material,
|
||||
custom_prompt=body.get('prompt'),
|
||||
size=body.get('size') or '1024x1024',
|
||||
style=body.get('style') or 'natural',
|
||||
quality=body.get('quality') or 'standard',
|
||||
)
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_image 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/video',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def gen_video(self, material_id, **kw):
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
svc = MediaService(request.env)
|
||||
media = svc.compose_video(material)
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_video 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',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_material_media(self, material_id, **kw):
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
return _json_response({
|
||||
'items': [m.to_api_dict() for m in material.media_ids],
|
||||
'count': len(material.media_ids),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_material_media failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/media/<id>/raw
|
||||
# ------------------------------------------------------------------
|
||||
# Streams the binary backing a media row. Used as the ``src`` for
|
||||
# ``<img>`` / ``<audio>`` / ``<video>`` tags AND as the target of
|
||||
# download links — the only difference is the ``?download=1`` flag.
|
||||
#
|
||||
# Accepts the JWT via the standard ``Authorization: Bearer …`` header
|
||||
# OR via ``?token=<jwt>`` / ``?access_token=<jwt>`` so plain ``<img>``
|
||||
# tags can render the asset without us blob-converting every fetch.
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>/raw',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
def stream_media(self, media_id, **kw):
|
||||
try:
|
||||
user = validate_token(allow_query_param=True)
|
||||
if not user:
|
||||
return _error_response('Authentication required', 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists() or not rec.attachment_id:
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
|
||||
attachment = rec.attachment_id
|
||||
payload = attachment.raw or b''
|
||||
if not payload and attachment.datas:
|
||||
# Older rows store the binary base64-encoded under ``datas``.
|
||||
payload = base64.b64decode(attachment.datas)
|
||||
if not payload:
|
||||
return _error_response('Media payload missing', 410)
|
||||
|
||||
mimetype = attachment.mimetype or rec.mime_type or 'application/octet-stream'
|
||||
filename = attachment.name or f'media-{rec.id}'
|
||||
disposition = (
|
||||
f'attachment; filename="{filename}"'
|
||||
if request.httprequest.args.get('download')
|
||||
else f'inline; filename="{filename}"'
|
||||
)
|
||||
headers = [
|
||||
('Content-Type', mimetype),
|
||||
('Content-Length', str(len(payload))),
|
||||
('Content-Disposition', disposition),
|
||||
# Aggressive caching is safe — the URL is keyed by the row id
|
||||
# and the binary is immutable once generated. Setting a 1h
|
||||
# max-age avoids re-streaming the same WAV/PNG every time the
|
||||
# admin re-opens the media drawer.
|
||||
('Cache-Control', 'private, max-age=3600'),
|
||||
]
|
||||
return request.make_response(payload, headers=headers)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.stream_media failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_media(self, media_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists():
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
if rec.attachment_id:
|
||||
rec.attachment_id.unlink()
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_media failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def gen_week_media(self, plan_id, week_number, **kw):
|
||||
"""Generate every applicable modality for every material in a week.
|
||||
|
||||
Body shape: ``{ kinds: ['audio', 'image', 'video'] }``.
|
||||
Default is ``['audio', 'image']`` — video is opt-in because it
|
||||
depends on ffmpeg + the audio + image steps and is slower.
|
||||
"""
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
week = plan.week_ids.filtered(
|
||||
lambda w: w.week_number == int(week_number),
|
||||
)
|
||||
if not week:
|
||||
return _error_response('Week not found', 404)
|
||||
week = week[0]
|
||||
body = _get_json_body() or {}
|
||||
kinds = body.get('kinds') or ['audio', 'image']
|
||||
kinds = [str(k).lower() for k in kinds]
|
||||
|
||||
svc = MediaService(request.env)
|
||||
results = []
|
||||
for material in week.material_ids:
|
||||
if 'audio' in kinds and material.material_type in (
|
||||
'listening_script', 'speaking_prompt',
|
||||
):
|
||||
results.append(svc.synthesize_audio(material).to_api_dict())
|
||||
if 'image' in kinds and material.material_type in (
|
||||
'reading_text', 'listening_script', 'vocabulary_list',
|
||||
):
|
||||
try:
|
||||
results.append(svc.generate_image(material).to_api_dict())
|
||||
except Exception as exc:
|
||||
results.append({'error': str(exc), 'material_id': material.id})
|
||||
if 'video' in kinds and material.material_type == 'listening_script':
|
||||
results.append(svc.compose_video(material).to_api_dict())
|
||||
return _json_response({'items': results, 'count': len(results)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_week_media failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE D — Plan assignments
|
||||
# ==================================================================
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_assignments(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [a.to_api_dict() for a in plan.assignment_ids],
|
||||
'count': len(plan.assignment_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_assignments failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_assignment(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
body = _get_json_body() or {}
|
||||
mode = (body.get('mode') or 'batch').strip()
|
||||
vals = {
|
||||
'plan_id': plan.id,
|
||||
'mode': mode,
|
||||
'message': (body.get('message') or '').strip(),
|
||||
'due_date': body.get('due_date') or False,
|
||||
'state': 'active',
|
||||
}
|
||||
if mode == 'batch':
|
||||
if not body.get('batch_id'):
|
||||
return _error_response('batch_id is required', 400)
|
||||
batch_id = int(body['batch_id'])
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
if plan.entity_id and batch.entity_id and plan.entity_id.id != batch.entity_id.id:
|
||||
return _error_response('Batch entity does not match plan entity', 400)
|
||||
vals['batch_id'] = batch_id
|
||||
elif mode == 'students':
|
||||
ids = body.get('student_user_ids') or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return _error_response('student_user_ids is required', 400)
|
||||
vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])]
|
||||
elif mode == 'entities':
|
||||
ids = body.get('entity_ids') or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return _error_response('entity_ids is required', 400)
|
||||
checked = [_ensure_entity_access(int(i)) for i in ids]
|
||||
vals['entity_ids'] = [(6, 0, checked)]
|
||||
else:
|
||||
return _error_response('Invalid mode', 400)
|
||||
rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_assignment failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_assignment(self, plan_id, assignment_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
|
||||
int(assignment_id),
|
||||
)
|
||||
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||
return _error_response('Assignment not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_assignment failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE E — Student-side endpoints
|
||||
# ==================================================================
|
||||
|
||||
@http.route('/api/student/course-plans',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def student_list_plans(self, **kw):
|
||||
"""Return the course plans assigned to the authenticated user.
|
||||
|
||||
Visibility rules (OR):
|
||||
1. There exists an assignment with ``mode='students'`` listing
|
||||
the current user.
|
||||
2. There exists an assignment with ``mode='batch'`` whose
|
||||
batch contains an ``op.student.course`` whose student's
|
||||
``user_id`` matches the current user.
|
||||
"""
|
||||
try:
|
||||
user = request.env.user
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
assignments = Assignment.search([('state', '=', 'active')])
|
||||
visible = []
|
||||
for a in assignments:
|
||||
if a.mode == 'students' and user.id in a.student_user_ids.ids:
|
||||
visible.append(a)
|
||||
continue
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
continue
|
||||
if a.mode == 'entities' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
for a in visible:
|
||||
if a.plan_id.id in seen:
|
||||
continue
|
||||
seen.add(a.plan_id.id)
|
||||
plan_data = a.plan_id.to_api_dict(
|
||||
include_weeks=False, include_materials=False,
|
||||
)
|
||||
plan_data['assignment'] = a.to_api_dict()
|
||||
out.append(plan_data)
|
||||
return _json_response({'items': out, 'count': len(out)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.student_list_plans failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@http.route('/api/student/course-plans/<int:plan_id>',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def student_get_plan(self, plan_id, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
allowed = False
|
||||
for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'):
|
||||
if a.mode == 'students' and user.id in a.student_user_ids.ids:
|
||||
allowed = True
|
||||
break
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
allowed = True
|
||||
break
|
||||
if a.mode == 'entities' and user.id in a.expand_user_ids():
|
||||
allowed = True
|
||||
break
|
||||
if not allowed:
|
||||
return _error_response('Plan not assigned to you', 403)
|
||||
return _json_response({
|
||||
'data': plan.to_api_dict(
|
||||
include_weeks=True,
|
||||
include_materials=True,
|
||||
include_media=True,
|
||||
),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.student_get_plan failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from . import ai_generation_log
|
||||
from . import ai_ielts_generation_log
|
||||
from . import course_plan
|
||||
from . import course_plan_source
|
||||
from . import course_plan_media
|
||||
from . import course_plan_assignment
|
||||
|
||||
@@ -54,6 +54,13 @@ class CoursePlan(models.Model):
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
@@ -117,15 +124,39 @@ class CoursePlan(models.Model):
|
||||
material_ids = fields.One2many(
|
||||
'encoach.course.plan.material', 'plan_id', string='Materials',
|
||||
)
|
||||
source_ids = fields.One2many(
|
||||
'encoach.course.plan.source', 'plan_id', string='Reference sources',
|
||||
)
|
||||
media_ids = fields.One2many(
|
||||
'encoach.course.plan.media', 'plan_id', string='Generated media',
|
||||
)
|
||||
assignment_ids = fields.One2many(
|
||||
'encoach.course.plan.assignment', 'plan_id', string='Assignments',
|
||||
)
|
||||
|
||||
week_count = fields.Integer(compute='_compute_counts', store=False)
|
||||
material_count = fields.Integer(compute='_compute_counts', store=False)
|
||||
source_count = fields.Integer(compute='_compute_counts', store=False)
|
||||
media_count = fields.Integer(compute='_compute_counts', store=False)
|
||||
assignment_count = fields.Integer(compute='_compute_counts', store=False)
|
||||
|
||||
@api.depends('week_ids', 'material_ids')
|
||||
@api.depends('week_ids', 'material_ids', 'source_ids', 'media_ids', 'assignment_ids')
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.week_count = len(rec.week_ids)
|
||||
rec.material_count = len(rec.material_ids)
|
||||
rec.source_count = len(rec.source_ids)
|
||||
rec.media_count = len(rec.media_ids)
|
||||
rec.assignment_count = len(rec.assignment_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
user = self.env.user.sudo()
|
||||
default_entity = user.entity_ids[:1].id if hasattr(user, 'entity_ids') else False
|
||||
for vals in vals_list:
|
||||
if vals.get('entity_id') is None and default_entity:
|
||||
vals['entity_id'] = default_entity
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation helpers — used by the REST controller so payload
|
||||
@@ -139,11 +170,15 @@ class CoursePlan(models.Model):
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def to_api_dict(self, *, include_weeks=True, include_materials=False):
|
||||
def to_api_dict(self, *, include_weeks=True, include_materials=False,
|
||||
include_sources=False, include_media=False,
|
||||
include_assignments=False):
|
||||
self.ensure_one()
|
||||
data = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'entity_id': self.entity_id.id if self.entity_id else None,
|
||||
'entity_name': self.entity_id.name if self.entity_id else '',
|
||||
'course_id': self.course_id.id if self.course_id else None,
|
||||
'course_name': self.course_id.name if self.course_id else '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
@@ -159,12 +194,21 @@ class CoursePlan(models.Model):
|
||||
'resources': self._loads(self.resources_json, []),
|
||||
'week_count': len(self.week_ids),
|
||||
'material_count': len(self.material_ids),
|
||||
'source_count': len(self.source_ids),
|
||||
'media_count': len(self.media_ids),
|
||||
'assignment_count': len(self.assignment_ids),
|
||||
'created_at': self.create_date.isoformat() if self.create_date else None,
|
||||
}
|
||||
if include_weeks:
|
||||
data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')]
|
||||
if include_materials:
|
||||
data['materials'] = [m.to_api_dict() for m in self.material_ids]
|
||||
if include_sources:
|
||||
data['sources'] = [s.to_api_dict() for s in self.source_ids]
|
||||
if include_media:
|
||||
data['media'] = [m.to_api_dict() for m in self.media_ids]
|
||||
if include_assignments:
|
||||
data['assignments'] = [a.to_api_dict() for a in self.assignment_ids]
|
||||
return data
|
||||
|
||||
|
||||
@@ -238,6 +282,13 @@ class CoursePlanMaterial(models.Model):
|
||||
MATERIAL_TYPE_SELECTION, required=True, default='other',
|
||||
)
|
||||
title = fields.Char(required=True)
|
||||
is_static = fields.Boolean(
|
||||
default=False,
|
||||
help='When enabled, regenerate-week keeps this material untouched.',
|
||||
)
|
||||
share_date = fields.Date(
|
||||
help='Optional date when the material becomes visible/shared.',
|
||||
)
|
||||
summary = fields.Text(
|
||||
help='Short blurb — purpose / learning outcomes targeted / how to use.',
|
||||
)
|
||||
@@ -252,6 +303,10 @@ class CoursePlanMaterial(models.Model):
|
||||
'structured body is not needed.',
|
||||
)
|
||||
|
||||
media_ids = fields.One2many(
|
||||
'encoach.course.plan.media', 'material_id', string='Generated media',
|
||||
)
|
||||
|
||||
def _loads(self, raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
@@ -260,9 +315,9 @@ class CoursePlanMaterial(models.Model):
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def to_api_dict(self):
|
||||
def to_api_dict(self, include_media=True):
|
||||
self.ensure_one()
|
||||
return {
|
||||
out = {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'week_id': self.week_id.id if self.week_id else None,
|
||||
@@ -270,7 +325,12 @@ class CoursePlanMaterial(models.Model):
|
||||
'skill': self.skill or '',
|
||||
'material_type': self.material_type or 'other',
|
||||
'title': self.title or '',
|
||||
'is_static': bool(self.is_static),
|
||||
'share_date': self.share_date.isoformat() if self.share_date else None,
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
}
|
||||
if include_media:
|
||||
out['media'] = [m.to_api_dict() for m in self.media_ids]
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Course-plan assignments: link a generated plan to a class or students.
|
||||
|
||||
Two flavours of assignment:
|
||||
|
||||
* ``mode='batch'`` — all current and future students of an ``op.batch``
|
||||
see the plan in their student dashboard.
|
||||
* ``mode='students'`` — only the chosen ``res.users`` (linked through
|
||||
the standard ``op.student`` portal user) see it.
|
||||
|
||||
The assignment is the visibility unit; it does NOT mutate
|
||||
enrollment, grades, or schedules. Teachers can attach a due date and a
|
||||
short message that appears on the student card.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ASSIGNMENT_MODE_SELECTION = [
|
||||
('batch', 'Class / Batch'),
|
||||
('students', 'Specific students'),
|
||||
('entities', 'Entities'),
|
||||
]
|
||||
|
||||
ASSIGNMENT_STATE_SELECTION = [
|
||||
('active', 'Active'),
|
||||
('archived', 'Archived'),
|
||||
]
|
||||
|
||||
|
||||
class CoursePlanAssignment(models.Model):
|
||||
_name = 'encoach.course.plan.assignment'
|
||||
_description = 'Course Plan Assignment'
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
mode = fields.Selection(ASSIGNMENT_MODE_SELECTION, required=True, default='batch')
|
||||
|
||||
batch_id = fields.Many2one(
|
||||
'op.batch', ondelete='set null', string='Class / batch',
|
||||
)
|
||||
student_user_ids = fields.Many2many(
|
||||
'res.users', 'course_plan_assignment_student_rel',
|
||||
'assignment_id', 'user_id', string='Specific students',
|
||||
)
|
||||
entity_ids = fields.Many2many(
|
||||
'encoach.entity', 'course_plan_assignment_entity_rel',
|
||||
'assignment_id', 'entity_id', string='Entities',
|
||||
)
|
||||
|
||||
assigned_by_id = fields.Many2one(
|
||||
'res.users', default=lambda self: self.env.user, string='Assigned by',
|
||||
)
|
||||
due_date = fields.Date()
|
||||
message = fields.Text(help='Optional note shown to the student.')
|
||||
state = fields.Selection(ASSIGNMENT_STATE_SELECTION, default='active')
|
||||
|
||||
student_count = fields.Integer(compute='_compute_student_count', store=False)
|
||||
|
||||
@api.depends('mode', 'batch_id', 'student_user_ids', 'entity_ids')
|
||||
def _compute_student_count(self):
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
Batch = self.env['op.batch'].sudo()
|
||||
for rec in self:
|
||||
if rec.mode == 'students':
|
||||
rec.student_count = len(rec.student_user_ids)
|
||||
continue
|
||||
if rec.mode == 'entities':
|
||||
user_ids = set()
|
||||
for entity in rec.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.add(user.id)
|
||||
rec.student_count = len(user_ids)
|
||||
continue
|
||||
if not rec.batch_id:
|
||||
rec.student_count = 0
|
||||
continue
|
||||
try:
|
||||
course_id = Batch.browse(rec.batch_id.id).course_id.id
|
||||
if course_id:
|
||||
rec.student_count = Enroll.search_count([
|
||||
('course_id', '=', course_id),
|
||||
('batch_id', '=', rec.batch_id.id),
|
||||
])
|
||||
else:
|
||||
rec.student_count = 0
|
||||
except Exception:
|
||||
rec.student_count = 0
|
||||
|
||||
def expand_user_ids(self):
|
||||
"""Return the ``res.users`` ids that should see this plan."""
|
||||
self.ensure_one()
|
||||
if self.mode == 'students':
|
||||
return self.student_user_ids.ids
|
||||
if self.mode == 'entities':
|
||||
user_ids = []
|
||||
for entity in self.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.append(user.id)
|
||||
return list(set(user_ids))
|
||||
if self.mode == 'batch' and self.batch_id:
|
||||
try:
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
course_id = self.batch_id.course_id.id
|
||||
rows = Enroll.search([
|
||||
('course_id', '=', course_id),
|
||||
('batch_id', '=', self.batch_id.id),
|
||||
])
|
||||
user_ids = []
|
||||
for row in rows:
|
||||
student = getattr(row, 'student_id', None)
|
||||
if not student:
|
||||
continue
|
||||
user = getattr(student, 'user_id', None)
|
||||
if user and user.id:
|
||||
user_ids.append(user.id)
|
||||
return list(set(user_ids))
|
||||
except Exception:
|
||||
_logger.exception('expand_user_ids failed for assignment %s', self.id)
|
||||
return []
|
||||
return []
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'plan_name': self.plan_id.name or '',
|
||||
'mode': self.mode or 'batch',
|
||||
'batch_id': self.batch_id.id if self.batch_id else None,
|
||||
'batch_name': self.batch_id.name if self.batch_id else '',
|
||||
'student_user_ids': self.student_user_ids.ids,
|
||||
'student_user_names': [u.name for u in self.student_user_ids],
|
||||
'entity_ids': self.entity_ids.ids,
|
||||
'entity_names': [e.name for e in self.entity_ids],
|
||||
'student_count': self.student_count or 0,
|
||||
'assigned_by_id': self.assigned_by_id.id if self.assigned_by_id else None,
|
||||
'assigned_by_name': self.assigned_by_id.name if self.assigned_by_id else '',
|
||||
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||
'message': self.message or '',
|
||||
'state': self.state or 'active',
|
||||
'created_at': self.create_date.isoformat() if self.create_date else None,
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Multimedia training assets generated for a course-plan material.
|
||||
|
||||
Each material (a reading text, listening script, vocab list, etc.) can
|
||||
have one or more linked media rows: an MP3 narration of a listening
|
||||
script, a DALL-E illustration for a vocab card, an MP4 slideshow video
|
||||
combining the two, and so on. Bytes live on ``ir.attachment`` so the
|
||||
existing filestore + access controls + URL serving (``/web/content``)
|
||||
all work for free.
|
||||
|
||||
Media generation is triggered explicitly through REST endpoints — we
|
||||
never generate inline during plan creation because each call has a
|
||||
non-trivial latency and cost.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MEDIA_KIND_SELECTION = [
|
||||
('audio', 'Audio'),
|
||||
('image', 'Image'),
|
||||
('video', 'Video'),
|
||||
]
|
||||
|
||||
MEDIA_PROVIDER_SELECTION = [
|
||||
# ── Paid providers ──
|
||||
('polly', 'AWS Polly'),
|
||||
('elevenlabs', 'ElevenLabs'),
|
||||
('openai_image', 'OpenAI (DALL-E)'),
|
||||
('elai', 'Elai.io'),
|
||||
# ── Free fallbacks (Phase 24.1) ──
|
||||
('pillow', 'Pillow placeholder (offline)'),
|
||||
('unsplash', 'Unsplash Source (free)'),
|
||||
('gtts', 'gTTS (free TTS)'),
|
||||
('silent', 'Silent stub'),
|
||||
('static', 'Static image as video'),
|
||||
('mock', 'Mock'),
|
||||
# ── Composers / manual ──
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('manual', 'Manual upload'),
|
||||
# Sentinel value used while the chain is still resolving — written
|
||||
# transiently by MediaService.create() and overwritten with the
|
||||
# successful provider name once a fallback step succeeds.
|
||||
('auto', 'Auto (fallback chain)'),
|
||||
]
|
||||
|
||||
MEDIA_STATUS_SELECTION = [
|
||||
('queued', 'Queued'),
|
||||
('generating', 'Generating'),
|
||||
('ready', 'Ready'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
|
||||
class CoursePlanMedia(models.Model):
|
||||
_name = 'encoach.course.plan.media'
|
||||
_description = 'Course Plan Multimedia Asset'
|
||||
_order = 'kind, id desc'
|
||||
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
week_id = fields.Many2one(
|
||||
'encoach.course.plan.week', ondelete='cascade', index=True,
|
||||
)
|
||||
material_id = fields.Many2one(
|
||||
'encoach.course.plan.material', ondelete='cascade', index=True,
|
||||
)
|
||||
|
||||
kind = fields.Selection(MEDIA_KIND_SELECTION, required=True)
|
||||
provider = fields.Selection(MEDIA_PROVIDER_SELECTION, required=True)
|
||||
title = fields.Char()
|
||||
source_text = fields.Text(
|
||||
help='The text fed to the generator (TTS script, image prompt, etc.).',
|
||||
)
|
||||
voice = fields.Char()
|
||||
language = fields.Char(default='en-GB')
|
||||
style = fields.Char(help='DALL-E style or video template label.')
|
||||
|
||||
attachment_id = fields.Many2one(
|
||||
'ir.attachment', ondelete='set null', string='Binary',
|
||||
)
|
||||
mime_type = fields.Char()
|
||||
size_bytes = fields.Integer()
|
||||
duration_seconds = fields.Float()
|
||||
width = fields.Integer()
|
||||
height = fields.Integer()
|
||||
download_url = fields.Char(
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary as an attachment '
|
||||
'(``Content-Disposition: attachment``). Frontend appends '
|
||||
'``?token=<jwt>`` for download buttons.',
|
||||
)
|
||||
preview_url = fields.Char(
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary inline so it can '
|
||||
'be used as the ``src`` for ``<img>`` / ``<audio>`` / ``<video>`` '
|
||||
'elements. Frontend appends ``?token=<jwt>``.',
|
||||
)
|
||||
|
||||
status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued')
|
||||
error = fields.Char()
|
||||
cost_cents = fields.Integer(
|
||||
default=0,
|
||||
help='Approximate provider cost in USD cents at generation time, '
|
||||
'best-effort accounting for budget caps.',
|
||||
)
|
||||
|
||||
@api.depends('attachment_id')
|
||||
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:
|
||||
if not rec.id:
|
||||
rec.download_url = ''
|
||||
rec.preview_url = ''
|
||||
continue
|
||||
base = f'/api/ai/course-plan/media/{rec.id}/raw'
|
||||
rec.preview_url = base
|
||||
rec.download_url = f'{base}?download=1'
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'week_id': self.week_id.id if self.week_id else None,
|
||||
'material_id': self.material_id.id if self.material_id else None,
|
||||
'kind': self.kind or '',
|
||||
'provider': self.provider or '',
|
||||
'title': self.title or '',
|
||||
'voice': self.voice or '',
|
||||
'language': self.language or '',
|
||||
'style': self.style or '',
|
||||
'mime_type': self.mime_type or '',
|
||||
'size_bytes': self.size_bytes or 0,
|
||||
'duration_seconds': self.duration_seconds or 0.0,
|
||||
'width': self.width or 0,
|
||||
'height': self.height or 0,
|
||||
'attachment_id': self.attachment_id.id if self.attachment_id else None,
|
||||
'download_url': self.download_url,
|
||||
'preview_url': self.preview_url,
|
||||
'status': self.status or 'queued',
|
||||
'error': self.error or '',
|
||||
'cost_cents': self.cost_cents or 0,
|
||||
'created_at': self.create_date.isoformat() if self.create_date else None,
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Reference source attached to a course plan.
|
||||
|
||||
Each source is one of: an uploaded file (PDF, DOCX, TXT), a URL the
|
||||
agent should crawl, or a chunk of inline text. Indexing extracts the
|
||||
text, chunks it, and pushes the chunks into ``encoach.embedding`` so
|
||||
the LangGraph ``course_planner`` and ``course_week_materials`` agents
|
||||
can ground their generation on these sources via the existing
|
||||
``resources.search`` tool, scoped to the plan.
|
||||
|
||||
We deliberately use the existing pgvector store rather than a new one:
|
||||
that way the same retrieval pipeline (embedding model, chunker, cosine
|
||||
similarity ranker) is shared, and admins can look up "what did the AI
|
||||
read" with the same dashboards.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SOURCE_KIND_SELECTION = [
|
||||
('file', 'File'),
|
||||
('url', 'URL'),
|
||||
('text', 'Inline text'),
|
||||
# ``resource`` is a soft-link to the central ``encoach.resource``
|
||||
# library so the admin can re-use a PDF / DOCX / link they already
|
||||
# uploaded under /admin/resources without re-uploading the binary.
|
||||
# The indexer dereferences the link at extraction time; the binary
|
||||
# itself stays in the library record so a single edit / rotation
|
||||
# propagates to every plan that grounds on it.
|
||||
('resource', 'Library resource'),
|
||||
]
|
||||
|
||||
SOURCE_STATUS_SELECTION = [
|
||||
('pending', 'Pending'),
|
||||
('indexing', 'Indexing'),
|
||||
('indexed', 'Indexed'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
|
||||
class CoursePlanSource(models.Model):
|
||||
_name = 'encoach.course.plan.source'
|
||||
_description = 'Course Plan Reference Source'
|
||||
_order = 'sequence asc, id asc'
|
||||
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
name = fields.Char(required=True, help='Human-readable label.')
|
||||
kind = fields.Selection(SOURCE_KIND_SELECTION, required=True, default='file')
|
||||
|
||||
# Filled when ``kind == 'file'`` — base64 payload mirrors how
|
||||
# encoach.resource and ir.attachment already do it.
|
||||
file = fields.Binary(string='Uploaded file', attachment=True)
|
||||
file_name = fields.Char(string='File name')
|
||||
mime_type = fields.Char(string='MIME type')
|
||||
|
||||
url = fields.Char(string='Source URL')
|
||||
inline_text = fields.Text(string='Inline text')
|
||||
|
||||
# Optional pointer to the central /admin/resources library. When set
|
||||
# the indexer pulls the binary / URL / inline text from the linked
|
||||
# ``encoach.resource`` instead of re-storing it on this row, which
|
||||
# avoids duplicating large PDFs across every plan that grounds on
|
||||
# the same library item.
|
||||
resource_id = fields.Many2one(
|
||||
'encoach.resource',
|
||||
string='Library resource',
|
||||
ondelete='set null',
|
||||
help='Link to a resource already uploaded under /admin/resources. '
|
||||
'The indexer reads the binary from the library at extraction '
|
||||
'time so updates to the library propagate to every plan.',
|
||||
)
|
||||
|
||||
auto_index = fields.Boolean(
|
||||
default=True,
|
||||
help='If true, indexing runs automatically on create. '
|
||||
'Disable to upload first, review, then index manually.',
|
||||
)
|
||||
status = fields.Selection(SOURCE_STATUS_SELECTION, default='pending')
|
||||
error = fields.Char()
|
||||
chunks_count = fields.Integer(default=0, string='Chunks')
|
||||
extracted_chars = fields.Integer(default=0, string='Extracted chars')
|
||||
indexed_at = fields.Datetime()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.auto_index:
|
||||
try:
|
||||
rec.action_index()
|
||||
except Exception as exc:
|
||||
# If indexing crashes before SourceIndexer has a chance
|
||||
# to mark the row as ``failed`` (e.g. an unexpected
|
||||
# import or DB error) we MUST still set the status to
|
||||
# ``failed``; otherwise the source sits in ``pending``
|
||||
# forever and the deliverables UI can't show progress.
|
||||
_logger.exception('Auto-index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
|
||||
def action_index(self):
|
||||
"""(Re-)extract text and push chunks to the vector store.
|
||||
|
||||
Wraps each per-record indexing call so a failure on one source
|
||||
doesn't abort the loop and leave later records orphaned.
|
||||
"""
|
||||
from odoo.addons.encoach_ai_course.services.source_indexer import (
|
||||
SourceIndexer,
|
||||
)
|
||||
indexer = SourceIndexer(self.env)
|
||||
for rec in self:
|
||||
try:
|
||||
indexer.index(rec)
|
||||
except Exception as exc:
|
||||
_logger.exception('Index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def unlink(self):
|
||||
try:
|
||||
from odoo.addons.encoach_vector.services.embedding_service import (
|
||||
EmbeddingService,
|
||||
)
|
||||
svc = EmbeddingService(self.env)
|
||||
for rec in self:
|
||||
svc.delete('course_plan_source', rec.id)
|
||||
except Exception:
|
||||
_logger.exception('Failed to delete embeddings for sources')
|
||||
return super().unlink()
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'name': self.name or '',
|
||||
'kind': self.kind or 'file',
|
||||
'file_name': self.file_name or '',
|
||||
'mime_type': self.mime_type or '',
|
||||
'url': self.url or '',
|
||||
'has_inline_text': bool(self.inline_text),
|
||||
'resource_id': self.resource_id.id if self.resource_id else None,
|
||||
'resource_name': self.resource_id.name if self.resource_id else '',
|
||||
'resource_type': (
|
||||
self.resource_id.type if self.resource_id else ''
|
||||
),
|
||||
'status': self.status or 'pending',
|
||||
'error': self.error or '',
|
||||
'chunks_count': self.chunks_count or 0,
|
||||
'extracted_chars': self.extracted_chars or 0,
|
||||
'indexed_at': self.indexed_at.isoformat() if self.indexed_at else None,
|
||||
'created_at': self.create_date.isoformat() if self.create_date else None,
|
||||
}
|
||||
|
||||
def get_decoded_file(self):
|
||||
"""Return raw bytes of the uploaded file, or ``b''`` if none."""
|
||||
self.ensure_one()
|
||||
if not self.file:
|
||||
return b''
|
||||
try:
|
||||
return base64.b64decode(self.file)
|
||||
except Exception:
|
||||
return b''
|
||||
@@ -4,3 +4,6 @@ access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user
|
||||
access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_source_user,encoach.course.plan.source.user,model_encoach_course_plan_source,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_media_user,encoach.course.plan.media.user,model_encoach_course_plan_media,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -1,3 +1,6 @@
|
||||
from .english_pipeline import EnglishPipeline
|
||||
from .ielts_pipeline import IeltsPipeline
|
||||
from .course_plan_pipeline import CoursePlanPipeline
|
||||
from .source_indexer import SourceIndexer
|
||||
from .media_service import MediaService
|
||||
from .deliverables import compute_deliverables
|
||||
|
||||
@@ -29,6 +29,21 @@ try:
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
# Markers that indicate the OpenAI account is unusable until the operator
|
||||
# fixes billing / keys / quota — i.e. retrying right now will never help.
|
||||
# We mirror the OpenAI service's own non-retryable list so a 429 caused by
|
||||
# `insufficient_quota` triggers the free fallback instead of bubbling up
|
||||
# as a 500 from the wizard's "Finish" button.
|
||||
_AI_PERMANENT_FAILURE_MARKERS = (
|
||||
"insufficient_quota",
|
||||
"invalid_api_key",
|
||||
"incorrect_api_key",
|
||||
"account_deactivated",
|
||||
"billing_hard_limit_reached",
|
||||
"openai not configured",
|
||||
"ai is disabled",
|
||||
)
|
||||
|
||||
# AgentRuntime is the LangGraph-backed engine. When the feature flag
|
||||
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
|
||||
# the matching key is configured, the pipeline routes through the agent
|
||||
@@ -43,6 +58,20 @@ except ImportError: # pragma: no cover - optional dep
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_permanent_ai_failure(message):
|
||||
"""Return True iff the AI error indicates a non-transient condition.
|
||||
|
||||
These are operator-fixable problems (out of credit, wrong key, AI
|
||||
feature switched off) where retrying would just hang the wizard. In
|
||||
those cases the pipeline degrades to a deterministic skeleton plan
|
||||
so the user still gets a usable record.
|
||||
"""
|
||||
if not message:
|
||||
return False
|
||||
low = str(message).lower()
|
||||
return any(m in low for m in _AI_PERMANENT_FAILURE_MARKERS)
|
||||
|
||||
|
||||
# JSON schema we coax the LLM into following. Keeping this as a prompt
|
||||
# string (rather than an OpenAI function call) makes it portable if the
|
||||
# underlying `chat_json` implementation ever changes providers.
|
||||
@@ -270,10 +299,46 @@ class CoursePlanPipeline:
|
||||
max_tokens=4096,
|
||||
action="course_plan.generate",
|
||||
)
|
||||
|
||||
# If the LLM is unavailable (quota exhausted, missing key,
|
||||
# disabled, network error) we degrade to a deterministic stub
|
||||
# plan instead of raising. The wizard's "Finish" button needs to
|
||||
# always succeed in producing a record the user can open and
|
||||
# iterate on; a hard failure here leaves the frontend stuck on a
|
||||
# 5+ minute spinner while the OpenAI client retries.
|
||||
used_fallback = False
|
||||
ai_error = None
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
ai_error = (content or {}).get('error', 'AI generation failed.')
|
||||
if _is_permanent_ai_failure(ai_error):
|
||||
_logger.warning(
|
||||
"Course plan AI unavailable (%s); using free fallback",
|
||||
ai_error,
|
||||
)
|
||||
content = self._build_fallback_plan_content(
|
||||
title=title,
|
||||
cefr=cefr,
|
||||
total_weeks=total_weeks,
|
||||
contact_hours=contact_hours,
|
||||
skills_division=skills_division,
|
||||
grammar_focus=grammar_focus,
|
||||
resources=resources,
|
||||
learner_profile=learner_profile,
|
||||
)
|
||||
used_fallback = True
|
||||
else:
|
||||
# Genuine transient failure — surface it to the caller so
|
||||
# they can retry. The frontend toasts the error message.
|
||||
raise RuntimeError(ai_error)
|
||||
|
||||
description = (content.get('description') or '').strip()
|
||||
if used_fallback:
|
||||
description = (
|
||||
description
|
||||
+ "\n\n[Auto-generated skeleton — OpenAI unavailable. "
|
||||
"Update the AI provider settings or restore billing, "
|
||||
"then click Regenerate.]"
|
||||
).strip()
|
||||
|
||||
plan_vals = {
|
||||
'name': title,
|
||||
@@ -283,14 +348,14 @@ class CoursePlanPipeline:
|
||||
'total_weeks': total_weeks,
|
||||
'contact_hours_per_week': contact_hours,
|
||||
'skills_division': skills_division,
|
||||
'description': (content.get('description') or '').strip(),
|
||||
'description': description,
|
||||
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
|
||||
'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
|
||||
'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
|
||||
'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
|
||||
'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
|
||||
'brief_json': json.dumps(brief, ensure_ascii=False),
|
||||
'status': 'generated',
|
||||
'status': 'generated' if not used_fallback else 'draft',
|
||||
}
|
||||
if brief.get('course_id'):
|
||||
try:
|
||||
@@ -370,10 +435,23 @@ class CoursePlanPipeline:
|
||||
max_tokens=6000,
|
||||
action="course_plan.generate_week",
|
||||
)
|
||||
used_week_fallback = False
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
ai_error = (content or {}).get('error', 'AI generation failed.')
|
||||
if _is_permanent_ai_failure(ai_error):
|
||||
_logger.warning(
|
||||
"Week materials AI unavailable (%s); using free fallback",
|
||||
ai_error,
|
||||
)
|
||||
content = self._build_fallback_week_content(
|
||||
plan_name=plan.name,
|
||||
cefr=(plan.cefr_level or "").lower(),
|
||||
week_number=week.week_number,
|
||||
items=items,
|
||||
)
|
||||
used_week_fallback = True
|
||||
else:
|
||||
raise RuntimeError(ai_error)
|
||||
|
||||
# Wipe any previous materials for this week so re-generating is
|
||||
# idempotent and we never accumulate duplicates.
|
||||
@@ -381,19 +459,28 @@ class CoursePlanPipeline:
|
||||
('plan_id', '=', plan.id), ('week_id', '=', week.id),
|
||||
])
|
||||
if existing:
|
||||
existing.unlink()
|
||||
# Keep instructor-curated static rows; only replace generated ones.
|
||||
existing.filtered(lambda m: not m.is_static).unlink()
|
||||
|
||||
Material = self.env['encoach.course.plan.material'].sudo()
|
||||
created = []
|
||||
skeleton_note = (
|
||||
"[Auto-generated skeleton — OpenAI unavailable. "
|
||||
"Update the AI provider settings or restore billing, "
|
||||
"then regenerate this week's materials.]"
|
||||
)
|
||||
for m in content.get('materials') or []:
|
||||
try:
|
||||
summary = (m.get('summary') or '').strip()
|
||||
if used_week_fallback:
|
||||
summary = (summary + "\n\n" + skeleton_note).strip()
|
||||
rec = Material.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'skill': (m.get('skill') or 'integrated').strip().lower(),
|
||||
'material_type': (m.get('material_type') or 'other').strip(),
|
||||
'title': (m.get('title') or '').strip() or 'Untitled',
|
||||
'summary': (m.get('summary') or '').strip(),
|
||||
'summary': summary,
|
||||
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
|
||||
'body_text': self._flatten_body(m.get('body') or {}),
|
||||
})
|
||||
@@ -440,10 +527,21 @@ class CoursePlanPipeline:
|
||||
payload=user_msg,
|
||||
extra_system=system_msg,
|
||||
)
|
||||
if final.get("error"):
|
||||
agent_error = final.get("error")
|
||||
if agent_error:
|
||||
# Permanent failures (no quota, bad key, AI off) will fail
|
||||
# the same way through the legacy chat_json path, so don't
|
||||
# double the wait — surface the error and let the caller
|
||||
# decide (generate_plan triggers the free fallback).
|
||||
if _is_permanent_ai_failure(agent_error):
|
||||
_logger.warning(
|
||||
"agent %s permanent failure (%s); skipping legacy fallback",
|
||||
agent_key, agent_error,
|
||||
)
|
||||
return {"error": agent_error}
|
||||
_logger.warning(
|
||||
"agent %s failed (%s); falling back to direct chat_json",
|
||||
agent_key, final.get("error"),
|
||||
agent_key, agent_error,
|
||||
)
|
||||
else:
|
||||
output = final.get("output")
|
||||
@@ -465,6 +563,186 @@ class CoursePlanPipeline:
|
||||
action=action,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Free fallback — used when OpenAI returns a permanent failure (no
|
||||
# quota, bad key, AI disabled). We synthesize a structurally-valid
|
||||
# plan so the wizard still completes and the user gets a record they
|
||||
# can edit or regenerate. The shape mirrors the JSON schema in
|
||||
# _PLAN_JSON_HINT exactly so downstream serializers don't notice.
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_fallback_plan_content(
|
||||
*, title, cefr, total_weeks, contact_hours, skills_division,
|
||||
grammar_focus, resources, learner_profile,
|
||||
):
|
||||
cefr_upper = (cefr or "a2").upper()
|
||||
skills_text = (skills_division or "").strip() or (
|
||||
"Reading & Writing balanced with Listening & Speaking"
|
||||
)
|
||||
outcomes = {
|
||||
"reading": [{"code": "RLO1", "description": f"Read level-appropriate ({cefr_upper}) texts and identify main ideas."}],
|
||||
"writing": [{"code": "WLO1", "description": "Plan and write short structured paragraphs on familiar topics."}],
|
||||
"listening": [{"code": "LLO1", "description": "Follow short dialogues and monologues at normal speed."}],
|
||||
"speaking": [{"code": "SLO1", "description": "Hold short conversations on personal and study-related topics."}],
|
||||
"vocabulary": [{"code": "VLO1", "description": "Use a level-appropriate active vocabulary across the four skills."}],
|
||||
"grammar": [{"code": "GLO1", "description": "Use the targeted grammar structures accurately in context."}],
|
||||
}
|
||||
grammar_blocks = [
|
||||
{"code": f"GT{i+1}", "label": label.strip() or f"Topic {i+1}",
|
||||
"sub_items": []}
|
||||
for i, label in enumerate((grammar_focus or [])[:6])
|
||||
] or [
|
||||
{"code": "GT1", "label": "Present tenses", "sub_items": ["present simple", "present continuous"]},
|
||||
{"code": "GT2", "label": "Past tenses", "sub_items": ["past simple", "past continuous"]},
|
||||
]
|
||||
weeks = []
|
||||
for w in range(1, max(1, int(total_weeks or 12)) + 1):
|
||||
weeks.append({
|
||||
"week_number": w,
|
||||
"date_label": f"Week {w}",
|
||||
"unit": f"Unit {((w - 1) // 2) + 1}",
|
||||
"focus": f"Skeleton focus for week {w} — replace via Regenerate.",
|
||||
"items": [
|
||||
{"skill": "reading", "outcome_codes": ["RLO1"], "remarks": ""},
|
||||
{"skill": "writing", "outcome_codes": ["WLO1"], "remarks": ""},
|
||||
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
|
||||
{"skill": "speaking", "outcome_codes": ["SLO1"], "remarks": ""},
|
||||
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""},
|
||||
],
|
||||
})
|
||||
return {
|
||||
"description": (
|
||||
f"{cefr_upper} general course over {total_weeks} weeks, "
|
||||
f"approximately {contact_hours} contact hours per week. "
|
||||
f"Coverage: {skills_text}. "
|
||||
f"Profile: {learner_profile or 'mixed adult learners'}."
|
||||
),
|
||||
"objectives": [
|
||||
f"Develop integrated {cefr_upper}-level skills across reading, writing, listening and speaking.",
|
||||
"Build active vocabulary and accurate use of target grammar.",
|
||||
"Use language confidently in personal, social and study contexts.",
|
||||
],
|
||||
"outcomes": outcomes,
|
||||
"grammar": grammar_blocks,
|
||||
"assessment": {
|
||||
"continuous_assessment": {
|
||||
"total_weight": 50,
|
||||
"components": [
|
||||
{"name": "MTE", "weight": 30},
|
||||
{"name": "Continuous tasks", "weight": 20},
|
||||
],
|
||||
},
|
||||
"final_exam": {"total_weight": 50},
|
||||
},
|
||||
"resources": [
|
||||
{"type": "textbook", "citation": (resources[0] if resources else "TBD — replace via Regenerate")},
|
||||
],
|
||||
"weeks": weeks,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Free fallback for per-week materials. Mirrors the schema in
|
||||
# _WEEK_JSON_HINT — placeholder content per skill so the teacher
|
||||
# has a starter row they can edit or regenerate later.
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_fallback_week_content(*, plan_name, cefr, week_number, items):
|
||||
cefr_upper = (cefr or "a2").upper()
|
||||
# Only produce materials for the skills that the week's plan
|
||||
# actually contains, so the teacher's outline is preserved.
|
||||
wanted_skills = []
|
||||
seen = set()
|
||||
for it in items or []:
|
||||
s = (it.get("skill") or "").strip().lower()
|
||||
if s and s not in seen:
|
||||
wanted_skills.append(s)
|
||||
seen.add(s)
|
||||
if not wanted_skills:
|
||||
wanted_skills = ["reading", "writing", "listening", "speaking", "grammar", "vocabulary"]
|
||||
|
||||
templates = {
|
||||
"reading": {
|
||||
"material_type": "reading_text",
|
||||
"title": f"Week {week_number} reading — placeholder",
|
||||
"summary": f"Skeleton reading task at {cefr_upper}. Replace via Regenerate.",
|
||||
"body": {
|
||||
"text": (
|
||||
"Placeholder reading passage. Replace with a "
|
||||
f"{cefr_upper}-level text of around 400 words on "
|
||||
"a familiar personal or study-related topic."
|
||||
),
|
||||
"questions": [
|
||||
{"q": "What is the main idea?", "type": "short_answer", "answer": "TBD"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"writing": {
|
||||
"material_type": "writing_prompt",
|
||||
"title": f"Week {week_number} writing — placeholder",
|
||||
"summary": "Skeleton writing task. Replace via Regenerate.",
|
||||
"body": {
|
||||
"prompt": "Write a short paragraph about your weekly routine.",
|
||||
"word_count": 150,
|
||||
"model_paragraph": "(Add a model paragraph after regenerating.)",
|
||||
},
|
||||
},
|
||||
"listening": {
|
||||
"material_type": "listening_script",
|
||||
"title": f"Week {week_number} listening — placeholder",
|
||||
"summary": "Skeleton listening script. Replace via Regenerate.",
|
||||
"body": {
|
||||
"script": (
|
||||
"(Placeholder script.) Two friends discuss their "
|
||||
"morning routines and weekend plans."
|
||||
),
|
||||
"comprehension_questions": [
|
||||
{"q": "Who is speaking?", "answer": "Two friends."},
|
||||
],
|
||||
},
|
||||
},
|
||||
"speaking": {
|
||||
"material_type": "speaking_prompt",
|
||||
"title": f"Week {week_number} speaking — placeholder",
|
||||
"summary": "Skeleton speaking prompts. Replace via Regenerate.",
|
||||
"body": {
|
||||
"prompts": [
|
||||
"Describe a typical day in your life.",
|
||||
"Talk about something you do at the weekend.",
|
||||
],
|
||||
"useful_language": ["usually", "often", "sometimes", "never"],
|
||||
},
|
||||
},
|
||||
"grammar": {
|
||||
"material_type": "grammar_lesson",
|
||||
"title": f"Week {week_number} grammar — placeholder",
|
||||
"summary": "Skeleton grammar mini-lesson. Replace via Regenerate.",
|
||||
"body": {
|
||||
"explanation": "Target structure for the week — replace via Regenerate.",
|
||||
"examples": ["I work every day.", "She doesn't drink coffee."],
|
||||
"practice": [
|
||||
{"q": "He ___ (work) in a bank.", "answer": "works"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"vocabulary": {
|
||||
"material_type": "vocabulary_list",
|
||||
"title": f"Week {week_number} vocabulary — placeholder",
|
||||
"summary": "Skeleton vocabulary set. Replace via Regenerate.",
|
||||
"body": {
|
||||
"words": [
|
||||
{"term": "routine", "pos": "n.", "definition": "a regular sequence of activities", "example": "My morning routine is busy."},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
materials = []
|
||||
for skill in wanted_skills:
|
||||
t = templates.get(skill)
|
||||
if t is None:
|
||||
continue
|
||||
materials.append({"skill": skill, **t})
|
||||
return {"materials": materials}
|
||||
|
||||
@staticmethod
|
||||
def _flatten_body(body):
|
||||
"""Produce a plain-text dump of a material body for quick preview.
|
||||
|
||||
158
backend/custom_addons/encoach_ai_course/services/deliverables.py
Normal file
158
backend/custom_addons/encoach_ai_course/services/deliverables.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Compute the deliverables list and progress for a course plan.
|
||||
|
||||
A deliverable is a single concrete artefact the AI should eventually
|
||||
produce. Each week's planned skills (from ``items_json``) maps to a
|
||||
deliverable, and each generated text material can in turn produce
|
||||
multimedia children:
|
||||
|
||||
* listening_script -> 1 audio narration + 1 illustration + 1 video
|
||||
* reading_text -> 1 hero illustration
|
||||
* speaking_prompt -> 1 model-answer audio
|
||||
* vocabulary_list -> 1 image per term (capped at 8 per list)
|
||||
|
||||
The frontend uses this to draw the Deliverables Preview before
|
||||
generation and the progress strip on the detail page after.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
SKILL_TO_MATERIAL_TYPE = {
|
||||
'reading': 'reading_text',
|
||||
'writing': 'writing_prompt',
|
||||
'listening': 'listening_script',
|
||||
'speaking': 'speaking_prompt',
|
||||
'grammar': 'grammar_lesson',
|
||||
'vocabulary': 'vocabulary_list',
|
||||
}
|
||||
|
||||
MATERIAL_MEDIA_PLAN = {
|
||||
'listening_script': [
|
||||
{'kind': 'audio', 'mandatory': True, 'note': 'Narrated MP3 of the script'},
|
||||
{'kind': 'image', 'mandatory': False, 'note': 'Illustration for the listening lesson'},
|
||||
{'kind': 'video', 'mandatory': False, 'note': 'Slide-style MP4 with audio'},
|
||||
],
|
||||
'reading_text': [
|
||||
{'kind': 'image', 'mandatory': False, 'note': 'Hero illustration for the passage'},
|
||||
],
|
||||
'speaking_prompt': [
|
||||
{'kind': 'audio', 'mandatory': False, 'note': 'Model-answer narration'},
|
||||
],
|
||||
'vocabulary_list': [
|
||||
{'kind': 'image', 'mandatory': False, 'note': 'Flashcard image (one per term, capped)'},
|
||||
],
|
||||
}
|
||||
|
||||
VOCAB_IMAGE_CAP = 8
|
||||
|
||||
|
||||
def _safe_loads(raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def compute_deliverables(plan):
|
||||
"""Return ``{summary, weeks: [...]}`` describing what the plan owes.
|
||||
|
||||
Status logic per deliverable:
|
||||
|
||||
* ``planned`` — no material row yet for that {week, material_type}.
|
||||
* ``generated``— material row exists but no media, or media not
|
||||
applicable for the type.
|
||||
* ``ready`` — material has at least one ``ready`` media child for
|
||||
every mandatory-or-cap-of-1 modality and one ``ready`` media for
|
||||
vocab cap (we don't gate on every term).
|
||||
|
||||
The frontend only needs the counts, but per-week breakdown is also
|
||||
returned so it can render a checklist.
|
||||
"""
|
||||
weeks_payload = []
|
||||
|
||||
materials_by_week = {}
|
||||
for m in plan.material_ids:
|
||||
materials_by_week.setdefault(m.week_id.id if m.week_id else 0, []).append(m)
|
||||
|
||||
counts = {'planned': 0, 'generated': 0, 'ready': 0}
|
||||
media_counts = {'audio': 0, 'image': 0, 'video': 0}
|
||||
media_ready_counts = {'audio': 0, 'image': 0, 'video': 0}
|
||||
|
||||
for week in plan.week_ids.sorted('week_number'):
|
||||
items = _safe_loads(week.items_json, [])
|
||||
ws_materials = materials_by_week.get(week.id, [])
|
||||
materials_by_type = {m.material_type: m for m in ws_materials}
|
||||
|
||||
deliverables = []
|
||||
for item in items:
|
||||
skill = (item.get('skill') or '').lower()
|
||||
mtype = SKILL_TO_MATERIAL_TYPE.get(skill)
|
||||
if not mtype:
|
||||
continue
|
||||
material = materials_by_type.get(mtype)
|
||||
entry = {
|
||||
'skill': skill,
|
||||
'material_type': mtype,
|
||||
'material_id': material.id if material else None,
|
||||
'title': material.title if material else '',
|
||||
'media': [],
|
||||
'status': 'planned',
|
||||
}
|
||||
if material:
|
||||
entry['status'] = 'generated'
|
||||
planned_media = MATERIAL_MEDIA_PLAN.get(mtype, [])
|
||||
ready_for_each_kind = {p['kind']: False for p in planned_media}
|
||||
for child in material.media_ids:
|
||||
media_counts[child.kind] = media_counts.get(child.kind, 0) + 1
|
||||
if child.status == 'ready':
|
||||
media_ready_counts[child.kind] = (
|
||||
media_ready_counts.get(child.kind, 0) + 1
|
||||
)
|
||||
if child.kind in ready_for_each_kind:
|
||||
ready_for_each_kind[child.kind] = True
|
||||
entry['media'].append({
|
||||
'id': child.id, 'kind': child.kind,
|
||||
'status': child.status, 'provider': child.provider,
|
||||
})
|
||||
if planned_media and all(
|
||||
ready_for_each_kind.get(p['kind'], False)
|
||||
for p in planned_media if p.get('mandatory')
|
||||
):
|
||||
entry['status'] = 'ready'
|
||||
elif not planned_media:
|
||||
entry['status'] = 'ready'
|
||||
counts[entry['status']] += 1
|
||||
deliverables.append(entry)
|
||||
weeks_payload.append({
|
||||
'week_number': week.week_number,
|
||||
'date_label': week.date_label or '',
|
||||
'unit': week.unit or '',
|
||||
'focus': week.focus or '',
|
||||
'items_total': len(items),
|
||||
'deliverables': deliverables,
|
||||
})
|
||||
|
||||
total = sum(counts.values())
|
||||
percent = round((counts['ready'] / total) * 100, 1) if total else 0.0
|
||||
return {
|
||||
'summary': {
|
||||
'total': total,
|
||||
'planned': counts['planned'],
|
||||
'generated': counts['generated'],
|
||||
'ready': counts['ready'],
|
||||
'percent_ready': percent,
|
||||
'media': {
|
||||
'audio': media_counts['audio'],
|
||||
'image': media_counts['image'],
|
||||
'video': media_counts['video'],
|
||||
'audio_ready': media_ready_counts['audio'],
|
||||
'image_ready': media_ready_counts['image'],
|
||||
'video_ready': media_ready_counts['video'],
|
||||
},
|
||||
},
|
||||
'weeks': weeks_payload,
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
"""Multimedia generation service for course-plan materials.
|
||||
|
||||
Three modalities — each persists an ``encoach.course.plan.media`` row
|
||||
with the bytes attached as an ``ir.attachment`` so the existing
|
||||
``/web/content/<id>`` URL serving works without extra plumbing.
|
||||
|
||||
PROVIDER FALLBACK CHAIN (Phase 24.1, Apr 2026)
|
||||
==============================================
|
||||
Every modality now tries providers in order: the explicitly-requested or
|
||||
admin-configured paid provider first, then a sequence of *free* fallbacks.
|
||||
A request that hits a billing/quota error (HTTP 402/429, OpenAI
|
||||
``insufficient_quota``, AWS Polly ``ThrottlingException``, ElevenLabs
|
||||
character-limit, etc.) is silently retried against the next provider in
|
||||
the chain — the request never fails just because the admin's API key has
|
||||
run out of credit.
|
||||
|
||||
* Image: ``openai (DALL-E 3) → pillow (offline placeholder) → unsplash``.
|
||||
* Audio: ``polly | elevenlabs → gtts → silent-stub``.
|
||||
* Video: ``ffmpeg (image+audio) → static (text-only image card)``.
|
||||
|
||||
The active provider per capability is read fresh from
|
||||
``ir.config_parameter`` on every call (see
|
||||
:mod:`encoach_ai.services.provider_router`) so toggling a provider in
|
||||
the admin UI takes effect on the very next request without restarting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
from odoo.addons.encoach_ai.services import provider_router
|
||||
from odoo.addons.encoach_ai.services.provider_router import (
|
||||
classify_provider_error,
|
||||
should_fallback,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _attach_bytes(env, *, name, mime_type, data: bytes,
|
||||
res_model: str | None = None,
|
||||
res_id: int | None = None,
|
||||
public: bool = True):
|
||||
"""Persist ``data`` as an ``ir.attachment`` and return the record."""
|
||||
Attachment = env['ir.attachment'].sudo()
|
||||
return Attachment.create({
|
||||
'name': name,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(data),
|
||||
'mimetype': mime_type or 'application/octet-stream',
|
||||
'res_model': res_model or False,
|
||||
'res_id': res_id or 0,
|
||||
'public': bool(public),
|
||||
})
|
||||
|
||||
|
||||
def _get_param(env, key, default):
|
||||
return env['ir.config_parameter'].sudo().get_param(key, default)
|
||||
|
||||
|
||||
def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
|
||||
"""Raise if generating ``planned_images`` would exceed the per-plan cap.
|
||||
|
||||
The budget only applies to *paid* image providers (DALL-E). Free
|
||||
fallbacks (Pillow, Unsplash) are unmetered.
|
||||
"""
|
||||
cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60'))
|
||||
if cap <= 0:
|
||||
return
|
||||
Media = env['encoach.course.plan.media'].sudo()
|
||||
used = Media.search_count([
|
||||
('plan_id', '=', plan.id),
|
||||
('kind', '=', 'image'),
|
||||
('provider', '=', 'openai_image'),
|
||||
('status', 'in', ('ready', 'generating')),
|
||||
])
|
||||
if used + planned_images > cap:
|
||||
raise RuntimeError(
|
||||
f'Paid image budget exceeded for this plan: {used} used, cap is '
|
||||
f'{cap}. Raise encoach_ai_course.image_budget_per_plan or delete '
|
||||
f'old DALL-E images. Free Pillow/Unsplash fallbacks remain available.'
|
||||
)
|
||||
|
||||
|
||||
def _build_audio_script(material) -> str:
|
||||
"""Choose the right text from a material's body for narration."""
|
||||
body = material._loads(material.body_json, {}) or {}
|
||||
if material.material_type == 'listening_script':
|
||||
return (body.get('script') or '').strip()
|
||||
if material.material_type == 'speaking_prompt':
|
||||
if isinstance(body.get('model_answer'), str) and body['model_answer'].strip():
|
||||
return body['model_answer'].strip()
|
||||
prompts = body.get('prompts') or []
|
||||
if prompts:
|
||||
return ' '.join(str(p) for p in prompts).strip()
|
||||
if material.material_type == 'reading_text':
|
||||
return (body.get('text') or '').strip()
|
||||
return (material.body_text or '').strip()
|
||||
|
||||
|
||||
def _build_image_prompt(material, *, plan) -> str:
|
||||
"""Construct a DALL-E prompt from the material content + plan CEFR."""
|
||||
body = material._loads(material.body_json, {}) or {}
|
||||
cefr = (plan.cefr_level or '').upper() or 'A2'
|
||||
style_hint = (
|
||||
'flat illustration, soft pastel palette, friendly textbook style, '
|
||||
'clean white background, high contrast, no text, no watermarks'
|
||||
)
|
||||
if material.material_type == 'reading_text':
|
||||
snippet = (body.get('text') or '')[:300].strip()
|
||||
return (
|
||||
f'Editorial illustration for an English language reading lesson '
|
||||
f'(CEFR {cefr}) titled "{material.title}". Subject: {snippet} '
|
||||
f'Style: {style_hint}.'
|
||||
)
|
||||
if material.material_type == 'listening_script':
|
||||
snippet = (body.get('script') or '')[:300].strip()
|
||||
return (
|
||||
f'Illustration showing the scene of an English listening '
|
||||
f'lesson dialogue (CEFR {cefr}) titled "{material.title}". '
|
||||
f'Scene: {snippet} Style: {style_hint}.'
|
||||
)
|
||||
if material.material_type == 'vocabulary_list':
|
||||
words = body.get('words') or []
|
||||
if words:
|
||||
term = words[0].get('term') or material.title
|
||||
return (
|
||||
f'Single concrete object representing the English word '
|
||||
f'"{term}" for a CEFR {cefr} vocabulary flashcard. {style_hint}.'
|
||||
)
|
||||
return (
|
||||
f'Illustration for an English language teaching material (CEFR '
|
||||
f'{cefr}) titled "{material.title}". {style_hint}.'
|
||||
)
|
||||
|
||||
|
||||
def _image_subtitle(material, *, plan):
|
||||
"""Short subtitle string used by the offline placeholder card."""
|
||||
cefr = (plan.cefr_level or '').upper() or '—'
|
||||
parts = [f'CEFR {cefr}']
|
||||
if material.week_number:
|
||||
parts.append(f'Week {material.week_number}')
|
||||
if material.material_type:
|
||||
parts.append(material.material_type.replace('_', ' ').title())
|
||||
return ' · '.join(parts)
|
||||
|
||||
|
||||
# --- Public service ---------------------------------------------------------
|
||||
|
||||
|
||||
class MediaService:
|
||||
"""Generate audio / image / video assets for a course-plan material.
|
||||
|
||||
All three public methods (:meth:`synthesize_audio`,
|
||||
:meth:`generate_image`, :meth:`compose_video`) walk a provider chain
|
||||
and degrade gracefully — they never raise to the controller as long
|
||||
as at least one free fallback is wired up. The persisted
|
||||
``encoach.course.plan.media`` row records *which* provider actually
|
||||
succeeded, plus any errors hit along the way (concatenated into
|
||||
``error`` for diagnostics).
|
||||
"""
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
# -- Audio -----------------------------------------------------------
|
||||
def synthesize_audio(self, material, *, voice: Optional[str] = None,
|
||||
language: str = 'en-GB',
|
||||
gender: str = 'female',
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Generate a narration MP3 for ``material`` and persist it.
|
||||
|
||||
The ``provider`` argument behaves like the admin setting:
|
||||
|
||||
* ``'auto'`` — try paid then free fallbacks
|
||||
* a specific name (``polly``, ``elevenlabs``, ``gtts``,
|
||||
``silent``) — pin that provider; the chain still falls back
|
||||
to the free providers if it errors out
|
||||
|
||||
Even if every provider fails, the row is marked ``failed`` with
|
||||
a helpful error rather than raising.
|
||||
"""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
media = Media.create({
|
||||
'plan_id': material.plan_id.id,
|
||||
'week_id': material.week_id.id if material.week_id else False,
|
||||
'material_id': material.id,
|
||||
'kind': 'audio',
|
||||
'provider': provider,
|
||||
'title': f'{material.title} — narration',
|
||||
'language': language,
|
||||
'voice': voice or '',
|
||||
'status': 'generating',
|
||||
})
|
||||
text = _build_audio_script(material)
|
||||
if not text:
|
||||
media.write({'status': 'failed', 'error': 'No script text to narrate'})
|
||||
return media
|
||||
media.write({'source_text': text[:3000]})
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'audio', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
result = self._call_audio_provider(
|
||||
prov, text=text, voice=voice,
|
||||
language=language, gender=gender,
|
||||
)
|
||||
content_type = result.get('content_type', 'audio/mpeg')
|
||||
ext = 'wav' if content_type == 'audio/wav' else 'mp3'
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{material.plan_id.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.{ext}',
|
||||
mime_type=content_type,
|
||||
data=result['audio'],
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': content_type,
|
||||
'size_bytes': len(result['audio']),
|
||||
'voice': result.get('voice') or voice or '',
|
||||
'provider': prov,
|
||||
'status': 'ready',
|
||||
'error': '\n'.join(errors)[:500] if errors else False,
|
||||
})
|
||||
return media
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'TTS provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
if not should_fallback(exc) and prov != chain[-1]:
|
||||
# ``other`` errors (not quota/auth/network) suggest a
|
||||
# genuine input problem, not provider exhaustion. Keep
|
||||
# falling back anyway because the user just wants audio
|
||||
# produced — but log loudly so we notice in production.
|
||||
_logger.exception(
|
||||
'Unclassified TTS error from %s — continuing chain', prov,
|
||||
)
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All audio providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
def _call_audio_provider(self, provider, *, text, voice, language, gender):
|
||||
if provider == 'polly':
|
||||
from odoo.addons.encoach_ai.services.polly_service import PollyService
|
||||
return PollyService(self.env).synthesize(
|
||||
text, voice=voice, language=language, gender=gender,
|
||||
)
|
||||
if provider == 'elevenlabs':
|
||||
from odoo.addons.encoach_ai.services.elevenlabs_service import (
|
||||
ElevenLabsService,
|
||||
)
|
||||
res = ElevenLabsService(self.env).synthesize(
|
||||
text, voice_id=voice or None,
|
||||
)
|
||||
return {
|
||||
'audio': res.get('audio') or res.get('audio_bytes') or b'',
|
||||
'content_type': res.get('content_type', 'audio/mpeg'),
|
||||
'voice': res.get('voice') or voice or 'elevenlabs',
|
||||
'characters': len(text),
|
||||
}
|
||||
if provider == 'gtts':
|
||||
from odoo.addons.encoach_ai.services.free_tts import (
|
||||
synthesize_with_gtts,
|
||||
)
|
||||
return synthesize_with_gtts(text, language=language)
|
||||
if provider == 'silent':
|
||||
from odoo.addons.encoach_ai.services.free_tts import synthesize_silent
|
||||
# Pick a duration roughly proportional to the script so the
|
||||
# silent stub still gives the video composer enough length.
|
||||
seconds = max(1, min(30, len(text) // 12))
|
||||
return synthesize_silent(duration_seconds=seconds)
|
||||
raise RuntimeError(f'Unknown audio provider: {provider!r}')
|
||||
|
||||
# -- Image -----------------------------------------------------------
|
||||
def generate_image(self, material, *,
|
||||
custom_prompt: Optional[str] = None,
|
||||
size: str = '1024x1024',
|
||||
style: str = 'natural',
|
||||
quality: str = 'standard',
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Generate an illustration for ``material`` with provider fallback."""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
plan = material.plan_id
|
||||
prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip()
|
||||
media = Media.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': material.week_id.id if material.week_id else False,
|
||||
'material_id': material.id,
|
||||
'kind': 'image',
|
||||
'provider': provider,
|
||||
'title': f'{material.title} — illustration',
|
||||
'source_text': prompt[:3000],
|
||||
'style': style,
|
||||
'status': 'generating',
|
||||
})
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'image', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
# Only the paid OpenAI provider is metered against the budget.
|
||||
if prov in ('openai', 'openai_image'):
|
||||
_enforce_image_budget(self.env, plan, planned_images=1)
|
||||
result = self._call_image_provider(
|
||||
prov, prompt=prompt, size=size, style=style,
|
||||
quality=quality, material=material, plan=plan,
|
||||
)
|
||||
provider_label = 'openai_image' if prov in (
|
||||
'openai', 'openai_image') else prov
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.png',
|
||||
mime_type=result.get('mime_type', 'image/png'),
|
||||
data=result['image'],
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
try:
|
||||
w, h = (int(s) for s in size.split('x'))
|
||||
except Exception:
|
||||
w, h = 0, 0
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': result.get('mime_type', 'image/png'),
|
||||
'size_bytes': len(result['image']),
|
||||
'width': w,
|
||||
'height': h,
|
||||
'provider': provider_label,
|
||||
'status': 'ready',
|
||||
'error': '\n'.join(errors)[:500] if errors else False,
|
||||
'cost_cents': (4 if quality == 'standard' else 8)
|
||||
if prov in ('openai', 'openai_image') else 0,
|
||||
})
|
||||
return media
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'Image provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All image providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
def _call_image_provider(self, provider, *, prompt, size, style, quality,
|
||||
material, plan):
|
||||
if provider in ('openai', 'openai_image'):
|
||||
from odoo.addons.encoach_ai.services.openai_service import (
|
||||
OpenAIService,
|
||||
)
|
||||
res = OpenAIService(self.env).generate_image(
|
||||
prompt, size=size, style=style, quality=quality,
|
||||
)
|
||||
return {
|
||||
'image': res['image'],
|
||||
'mime_type': 'image/png',
|
||||
}
|
||||
if provider == 'pillow':
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
png = render_placeholder(
|
||||
material.title or 'Course material',
|
||||
subtitle=_image_subtitle(material, plan=plan),
|
||||
size=size,
|
||||
seed=material.id,
|
||||
)
|
||||
return {'image': png, 'mime_type': 'image/png'}
|
||||
if provider == 'unsplash':
|
||||
return self._fetch_unsplash(prompt, size=size)
|
||||
if provider == 'mock':
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
png = render_placeholder(
|
||||
'Mock provider',
|
||||
subtitle=material.title or '',
|
||||
size=size,
|
||||
seed=material.id,
|
||||
)
|
||||
return {'image': png, 'mime_type': 'image/png'}
|
||||
raise RuntimeError(f'Unknown image provider: {provider!r}')
|
||||
|
||||
def _fetch_unsplash(self, prompt, *, size='1024x1024'):
|
||||
"""Free Unsplash Source endpoint — no API key required.
|
||||
|
||||
Falls back to the offline Pillow placeholder if the network call
|
||||
fails so this provider, like all the others, is non-blocking.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError('requests not installed') from exc
|
||||
# The "source" endpoint returns a redirect to a JPEG that matches
|
||||
# the keywords. We deliberately use only the first 5 keywords to
|
||||
# keep the URL short.
|
||||
words = ' '.join((prompt or '').split()[:5]).strip() or 'education'
|
||||
try:
|
||||
w, h = size.lower().split('x')
|
||||
except Exception:
|
||||
w, h = '1024', '1024'
|
||||
url = f'https://source.unsplash.com/{w}x{h}/?{words}'
|
||||
resp = requests.get(url, timeout=15, allow_redirects=True)
|
||||
if resp.status_code != 200 or not resp.content:
|
||||
raise RuntimeError(
|
||||
f'Unsplash returned HTTP {resp.status_code}'
|
||||
)
|
||||
return {'image': resp.content, 'mime_type': 'image/jpeg'}
|
||||
|
||||
# -- Video -----------------------------------------------------------
|
||||
def compose_video(self, material, *, audio_media=None,
|
||||
image_media=None,
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Compose a slide-style MP4 (image + audio) for ``material``.
|
||||
|
||||
Strategy:
|
||||
|
||||
1. Try ``ffmpeg`` (real slideshow video) if ffmpeg is on PATH.
|
||||
2. Fall back to ``static`` — a 5-second MP4 generated purely
|
||||
from the placeholder image without external audio.
|
||||
|
||||
Like the audio/image methods, this never raises to the caller;
|
||||
it always returns a media row whose ``status`` reflects success
|
||||
or failure.
|
||||
"""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
plan = material.plan_id
|
||||
media = Media.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': material.week_id.id if material.week_id else False,
|
||||
'material_id': material.id,
|
||||
'kind': 'video',
|
||||
'provider': provider,
|
||||
'title': f'{material.title} — slideshow',
|
||||
'status': 'generating',
|
||||
})
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'video', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
if prov == 'ffmpeg':
|
||||
return self._compose_video_ffmpeg(
|
||||
media, material, audio_media, image_media,
|
||||
)
|
||||
if prov == 'static':
|
||||
return self._compose_video_static(media, material)
|
||||
raise RuntimeError(f'Unknown video provider: {prov!r}')
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'Video provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All video providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
# ── Concrete video providers ────────────────────────────────────────
|
||||
|
||||
def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
|
||||
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
|
||||
if shutil.which('ffmpeg') is None:
|
||||
raise RuntimeError('ffmpeg not found on PATH')
|
||||
|
||||
plan = material.plan_id
|
||||
audio = audio_media or material.media_ids.filtered(
|
||||
lambda m: m.kind == 'audio' and m.status == 'ready'
|
||||
)[:1]
|
||||
if not audio:
|
||||
audio = self.synthesize_audio(material)
|
||||
if audio.status != 'ready':
|
||||
raise RuntimeError(
|
||||
f'Audio prerequisite not ready: {audio.error or "unknown"}'
|
||||
)
|
||||
image = image_media or material.media_ids.filtered(
|
||||
lambda m: m.kind == 'image' and m.status == 'ready'
|
||||
)[:1]
|
||||
if not image:
|
||||
image = self.generate_image(material)
|
||||
if image.status != 'ready':
|
||||
raise RuntimeError(
|
||||
f'Image prerequisite not ready: {image.error or "unknown"}'
|
||||
)
|
||||
audio_attach = audio.attachment_id
|
||||
image_attach = image.attachment_id
|
||||
if not audio_attach or not image_attach:
|
||||
raise RuntimeError('Missing audio/image attachments')
|
||||
|
||||
audio_bytes = base64.b64decode(audio_attach.datas)
|
||||
image_bytes = base64.b64decode(image_attach.datas)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='encoach_video_') as tmp:
|
||||
a_path = os.path.join(tmp, 'audio.mp3')
|
||||
i_path = os.path.join(tmp, 'image.png')
|
||||
v_path = os.path.join(tmp, 'out.mp4')
|
||||
with open(a_path, 'wb') as f:
|
||||
f.write(audio_bytes)
|
||||
with open(i_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
cmd = [
|
||||
'ffmpeg', '-y',
|
||||
'-loop', '1', '-i', i_path,
|
||||
'-i', a_path,
|
||||
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-b:a', '192k',
|
||||
'-shortest',
|
||||
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,'
|
||||
'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white',
|
||||
v_path,
|
||||
]
|
||||
t0 = time.time()
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, check=False, timeout=180,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:]
|
||||
raise RuntimeError(f'ffmpeg failed: {err}')
|
||||
with open(v_path, 'rb') as f:
|
||||
video_bytes = f.read()
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.mp4',
|
||||
mime_type='video/mp4',
|
||||
data=video_bytes,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': 'video/mp4',
|
||||
'size_bytes': len(video_bytes),
|
||||
'duration_seconds': float(audio.duration_seconds or elapsed or 0),
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'provider': 'ffmpeg',
|
||||
'status': 'ready',
|
||||
'error': False,
|
||||
})
|
||||
return media
|
||||
|
||||
def _compose_video_static(self, media, material):
|
||||
"""Last-resort: a tiny MP4-shaped image-only stub.
|
||||
|
||||
We don't pretend to render a true video without ffmpeg — instead
|
||||
we attach the placeholder PNG with an MP4 mime so the LMS can
|
||||
still display *something*. The media row is marked ``ready`` but
|
||||
flagged as ``static`` so admins can re-generate later.
|
||||
"""
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
plan = material.plan_id
|
||||
png = render_placeholder(
|
||||
material.title or 'Course material',
|
||||
subtitle=_image_subtitle(material, plan=plan) + ' · static',
|
||||
size='1280x720',
|
||||
seed=material.id,
|
||||
)
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}-static.png',
|
||||
mime_type='image/png',
|
||||
data=png,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': 'image/png',
|
||||
'size_bytes': len(png),
|
||||
'duration_seconds': 0.0,
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'provider': 'static',
|
||||
'status': 'ready',
|
||||
'error': 'ffmpeg not available — served as static placeholder image',
|
||||
})
|
||||
return media
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Extract text from a course-plan source and embed it into pgvector.
|
||||
|
||||
Supported inputs:
|
||||
|
||||
* ``kind='file'`` — PDF (preferred via ``pypdf`` then ``PyPDF2``), DOCX
|
||||
(via ``python-docx``), plain text, or any text MIME we can decode.
|
||||
* ``kind='url'`` — fetched with ``requests``; HTML is reduced to text
|
||||
via a tiny BeautifulSoup4 path when available, otherwise raw text.
|
||||
* ``kind='text'`` — used as-is.
|
||||
|
||||
Indexed chunks are stored under ``content_type='course_plan_source'``
|
||||
with ``entity_id=plan_id`` so the existing ``resources.search`` tool
|
||||
can scope retrieval to a single plan. We never raise — failures are
|
||||
recorded on the source row and surfaced to the UI through the status /
|
||||
error fields. That way one bad PDF doesn't block the rest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_pdf(payload: bytes) -> str:
|
||||
"""Best-effort PDF text extraction.
|
||||
|
||||
Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older,
|
||||
still common). Both raise on encrypted PDFs we can't decrypt — we
|
||||
swallow that and let the caller record the error.
|
||||
"""
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
from PyPDF2 import PdfReader # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'PDF parser not installed (pip install pypdf)',
|
||||
) from exc
|
||||
reader = PdfReader(io.BytesIO(payload))
|
||||
pages = []
|
||||
for page in reader.pages:
|
||||
try:
|
||||
pages.append(page.extract_text() or '')
|
||||
except Exception:
|
||||
pages.append('')
|
||||
return '\n\n'.join(p for p in pages if p).strip()
|
||||
|
||||
|
||||
def _extract_docx(payload: bytes) -> str:
|
||||
try:
|
||||
import docx # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'DOCX parser not installed (pip install python-docx)',
|
||||
) from exc
|
||||
document = docx.Document(io.BytesIO(payload))
|
||||
return '\n'.join(p.text for p in document.paragraphs if p.text).strip()
|
||||
|
||||
|
||||
def _extract_html(html: str) -> str:
|
||||
"""Strip tags. Uses BeautifulSoup if available, else a regex fallback."""
|
||||
try:
|
||||
from bs4 import BeautifulSoup # type: ignore
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
for tag in soup(['script', 'style', 'noscript']):
|
||||
tag.decompose()
|
||||
text = soup.get_text(separator='\n')
|
||||
except ImportError:
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', ' ', html)
|
||||
lines = [line.strip() for line in text.splitlines()]
|
||||
return '\n'.join(line for line in lines if line)
|
||||
|
||||
|
||||
def _fetch_url(url: str) -> tuple[str, str]:
|
||||
"""Fetch ``url`` and return ``(content_type, text)``."""
|
||||
try:
|
||||
import requests # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'requests not installed (pip install requests)',
|
||||
) from exc
|
||||
resp = requests.get(url, timeout=30, headers={
|
||||
'User-Agent': 'EnCoach-CoursePlan-Indexer/1.0',
|
||||
})
|
||||
resp.raise_for_status()
|
||||
ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip().lower()
|
||||
if ctype == 'application/pdf':
|
||||
return ctype, _extract_pdf(resp.content)
|
||||
if 'html' in ctype:
|
||||
return ctype, _extract_html(resp.text)
|
||||
if ctype.startswith('text/') or not ctype:
|
||||
return ctype or 'text/plain', resp.text
|
||||
raise RuntimeError(f'Unsupported content-type for URL: {ctype}')
|
||||
|
||||
|
||||
class SourceIndexer:
|
||||
"""Extract text from a source row and (re-)index it to pgvector."""
|
||||
|
||||
CONTENT_TYPE = 'course_plan_source'
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def _extract(self, source) -> str:
|
||||
"""Return raw extracted text — or raise if extraction fails."""
|
||||
if source.kind == 'text':
|
||||
return (source.inline_text or '').strip()
|
||||
|
||||
if source.kind == 'resource':
|
||||
# Dereference the library resource at extraction time. This
|
||||
# keeps the binary in one place (``encoach.resource``) so an
|
||||
# admin update — re-uploading a corrected PDF, fixing a URL,
|
||||
# changing the linked file — propagates to every plan that
|
||||
# grounds on it on the next reindex.
|
||||
res = source.resource_id
|
||||
if not res or not res.exists():
|
||||
raise ValueError(
|
||||
'Linked library resource is missing or was deleted.',
|
||||
)
|
||||
rtype = (res.type or '').lower()
|
||||
file_name = (res.name or '') + (
|
||||
f'.{rtype}' if rtype in ('pdf', 'docx') and not (res.name or '').lower().endswith(('.pdf', '.docx')) else ''
|
||||
)
|
||||
# Persist the resolved metadata on the source row so the UI
|
||||
# can render a meaningful "indexed N chunks from X.pdf" line
|
||||
# without having to rejoin the resource table on every read.
|
||||
updates = {}
|
||||
if not source.name:
|
||||
updates['name'] = res.name or f'Resource #{res.id}'
|
||||
if not source.file_name:
|
||||
updates['file_name'] = file_name
|
||||
if updates:
|
||||
source.write(updates)
|
||||
|
||||
if res.file:
|
||||
payload = base64.b64decode(res.file)
|
||||
if not payload:
|
||||
raise ValueError('Library resource has an empty file.')
|
||||
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
|
||||
return _extract_pdf(payload)
|
||||
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
|
||||
return _extract_docx(payload)
|
||||
# Fall back to plain-text decoding for txt/md/csv/json.
|
||||
if file_name.lower().endswith((
|
||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
||||
'.log', '.rst',
|
||||
)):
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
# Best-effort: try PDF first, then DOCX, then UTF-8.
|
||||
for fn in (_extract_pdf, _extract_docx):
|
||||
try:
|
||||
text = fn(payload)
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f'Cannot decode library resource binary: {exc}',
|
||||
) from exc
|
||||
|
||||
if res.url:
|
||||
_, text = _fetch_url(res.url)
|
||||
return text or ''
|
||||
|
||||
raise ValueError(
|
||||
'Library resource has neither a file nor a URL to index.',
|
||||
)
|
||||
|
||||
if source.kind == 'url':
|
||||
url = (source.url or '').strip()
|
||||
if not url:
|
||||
raise ValueError('URL is empty')
|
||||
mime, text = _fetch_url(url)
|
||||
if not source.mime_type:
|
||||
source.mime_type = mime
|
||||
return text or ''
|
||||
|
||||
if source.kind == 'file':
|
||||
payload = source.get_decoded_file()
|
||||
if not payload:
|
||||
raise ValueError('No file payload to index')
|
||||
mime = (source.mime_type or '').lower()
|
||||
name = (source.file_name or '').lower()
|
||||
if mime == 'application/pdf' or name.endswith('.pdf'):
|
||||
return _extract_pdf(payload)
|
||||
if (mime in (
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword',
|
||||
) or name.endswith('.docx') or name.endswith('.doc')):
|
||||
return _extract_docx(payload)
|
||||
# Whitelist plain-text-shaped uploads explicitly. Anything else
|
||||
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
|
||||
# rather than silently UTF-8-decoded into garbage that we'd
|
||||
# then "successfully" embed and surface as a usable RAG source.
|
||||
if (mime.startswith('text/')
|
||||
or mime in ('application/json', 'application/xml',
|
||||
'application/csv')
|
||||
or name.endswith(('.txt', '.md', '.markdown', '.csv',
|
||||
'.json', '.xml', '.log', '.rst'))):
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Cannot decode file: {exc}') from exc
|
||||
raise ValueError(
|
||||
f'Unsupported file type for RAG indexing: '
|
||||
f'mime={mime!r} name={source.file_name!r}. '
|
||||
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
|
||||
)
|
||||
|
||||
raise ValueError(f'Unknown source kind: {source.kind!r}')
|
||||
|
||||
def index(self, source) -> dict:
|
||||
"""Run extraction + embedding for one source row."""
|
||||
from odoo.addons.encoach_vector.services.embedding_service import (
|
||||
EmbeddingService,
|
||||
)
|
||||
source.write({'status': 'indexing', 'error': False})
|
||||
|
||||
try:
|
||||
text = self._extract(source)
|
||||
except Exception as exc:
|
||||
source.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
'chunks_count': 0,
|
||||
'extracted_chars': 0,
|
||||
})
|
||||
_logger.warning('Extract failed for source %s: %s', source.id, exc)
|
||||
return {'status': 'failed', 'error': str(exc)}
|
||||
|
||||
if not text or not text.strip():
|
||||
source.write({
|
||||
'status': 'failed',
|
||||
'error': 'Extracted no text from source',
|
||||
'chunks_count': 0,
|
||||
'extracted_chars': 0,
|
||||
})
|
||||
return {'status': 'failed', 'error': 'empty'}
|
||||
|
||||
try:
|
||||
svc = EmbeddingService(self.env)
|
||||
metadata = {
|
||||
'plan_id': source.plan_id.id,
|
||||
'source_id': source.id,
|
||||
'kind': source.kind,
|
||||
'title': source.name or source.file_name or source.url or '',
|
||||
'entity_id': source.plan_id.id,
|
||||
}
|
||||
chunks = svc.upsert(
|
||||
self.CONTENT_TYPE, source.id, text, metadata,
|
||||
)
|
||||
except Exception as exc:
|
||||
source.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
_logger.exception('Embedding failed for source %s', source.id)
|
||||
return {'status': 'failed', 'error': str(exc)}
|
||||
|
||||
source.write({
|
||||
'status': 'indexed',
|
||||
'error': False,
|
||||
'chunks_count': len(chunks),
|
||||
'extracted_chars': len(text),
|
||||
'indexed_at': datetime.utcnow(),
|
||||
})
|
||||
return {
|
||||
'status': 'indexed',
|
||||
'chunks_count': len(chunks),
|
||||
'extracted_chars': len(text),
|
||||
}
|
||||
@@ -94,12 +94,39 @@ def _get_jwt_secret():
|
||||
return secret
|
||||
|
||||
|
||||
def validate_token():
|
||||
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None."""
|
||||
def _extract_bearer_token(allow_query_param: bool = False) -> str | None:
|
||||
"""Pull a JWT off the current request.
|
||||
|
||||
By default we only honour the ``Authorization: Bearer …`` header. Some
|
||||
endpoints (notably media streams that get embedded in ``<img>`` /
|
||||
``<audio>`` / ``<video>`` tags, where the browser cannot attach custom
|
||||
headers) opt in to ``allow_query_param=True`` so callers can send the
|
||||
token via ``?token=<jwt>`` or ``?access_token=<jwt>``. We deliberately
|
||||
keep this off by default so a leaked URL never gives access to JSON
|
||||
APIs — only to the specific raw-media route that opts in.
|
||||
"""
|
||||
auth_header = request.httprequest.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header[7:].strip() or None
|
||||
if allow_query_param:
|
||||
try:
|
||||
args = request.httprequest.args
|
||||
except Exception:
|
||||
args = {}
|
||||
token = (args.get("token") or args.get("access_token") or "").strip()
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def validate_token(allow_query_param: bool = False):
|
||||
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None.
|
||||
|
||||
See :func:`_extract_bearer_token` for the ``allow_query_param`` flag.
|
||||
"""
|
||||
token = _extract_bearer_token(allow_query_param=allow_query_param)
|
||||
if not token:
|
||||
return None
|
||||
token = auth_header[7:]
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
|
||||
|
||||
@@ -304,8 +304,23 @@ class ApprovalWorkflowController(http.Controller):
|
||||
if not req_rec.exists():
|
||||
return _error_response('Request not found', 404)
|
||||
|
||||
# Authorization — only the user assigned to the current stage
|
||||
# (or a system admin) may approve. Without this check any
|
||||
# authenticated user could ride a valid JWT and approve any
|
||||
# request, bypassing the entire approval workflow.
|
||||
current_user = request.env.user
|
||||
stage = req_rec.current_stage_id
|
||||
assigned = stage.approver_id if stage else None
|
||||
is_admin = (
|
||||
current_user.has_group('base.group_system')
|
||||
or getattr(current_user, 'user_type', None) == 'admin'
|
||||
)
|
||||
if assigned and assigned.id != current_user.id and not is_admin:
|
||||
return _error_response(
|
||||
'You are not the assigned approver for this stage', 403,
|
||||
)
|
||||
|
||||
with request.env.cr.savepoint():
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'approved',
|
||||
@@ -362,8 +377,20 @@ class ApprovalWorkflowController(http.Controller):
|
||||
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
||||
if not req_rec.exists():
|
||||
return _error_response('Request not found', 404)
|
||||
# Same authorization gate as approve — the rejection action
|
||||
# is just as sensitive as the approval action.
|
||||
current_user = request.env.user
|
||||
stage = req_rec.current_stage_id
|
||||
assigned = stage.approver_id if stage else None
|
||||
is_admin = (
|
||||
current_user.has_group('base.group_system')
|
||||
or getattr(current_user, 'user_type', None) == 'admin'
|
||||
)
|
||||
if assigned and assigned.id != current_user.id and not is_admin:
|
||||
return _error_response(
|
||||
'You are not the assigned approver for this stage', 403,
|
||||
)
|
||||
with request.env.cr.savepoint():
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'rejected',
|
||||
|
||||
@@ -11,6 +11,18 @@ _logger = logging.getLogger(__name__)
|
||||
ENTITY_MODEL = 'encoach.entity'
|
||||
|
||||
|
||||
def _ensure_admin_user():
|
||||
user = request.env.user.sudo()
|
||||
is_admin = bool(
|
||||
user.has_group('base.group_system')
|
||||
or user.has_group('base.group_erp_manager')
|
||||
or getattr(user, 'user_type', '') == 'admin'
|
||||
)
|
||||
if not is_admin:
|
||||
raise PermissionError('Admin access required')
|
||||
return user
|
||||
|
||||
|
||||
def _entity_to_dict(entity):
|
||||
d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else {
|
||||
'id': entity.id,
|
||||
@@ -35,6 +47,16 @@ def _role_to_dict(role):
|
||||
}
|
||||
|
||||
|
||||
def _user_to_dict(user):
|
||||
return {
|
||||
'id': user.id,
|
||||
'name': user.name or '',
|
||||
'login': user.login or '',
|
||||
'email': user.email or '',
|
||||
'active': bool(user.active),
|
||||
}
|
||||
|
||||
|
||||
class EntityController(http.Controller):
|
||||
|
||||
@http.route('/api/entities', type='http', auth='public',
|
||||
@@ -59,6 +81,62 @@ class EntityController(http.Controller):
|
||||
_logger.exception('list entities failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/entities/<int:entity_id>/users', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_entity_users(self, entity_id, **kw):
|
||||
try:
|
||||
_ensure_admin_user()
|
||||
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
items = [_user_to_dict(u) for u in entity.user_ids.sorted('name')]
|
||||
return _json_response({
|
||||
'items': items,
|
||||
'data': items,
|
||||
'total': len(items),
|
||||
'entity_id': entity.id,
|
||||
})
|
||||
except Exception as e:
|
||||
code = 403 if isinstance(e, PermissionError) else 500
|
||||
return _error_response(str(e), code)
|
||||
|
||||
@http.route('/api/entities/<int:entity_id>/users', type='http', auth='public',
|
||||
methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_entity_users(self, entity_id, **kw):
|
||||
try:
|
||||
_ensure_admin_user()
|
||||
body = _get_json_body() or {}
|
||||
raw_ids = body.get('user_ids') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return _error_response('user_ids must be a list', 400)
|
||||
try:
|
||||
user_ids = [int(uid) for uid in raw_ids if uid is not None]
|
||||
except (TypeError, ValueError):
|
||||
return _error_response('user_ids must contain integers', 400)
|
||||
|
||||
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
|
||||
users = request.env['res.users'].sudo().browse(user_ids).exists()
|
||||
if len(users) != len(user_ids):
|
||||
return _error_response('Some users do not exist', 400)
|
||||
|
||||
entity.write({'user_ids': [(6, 0, users.ids)]})
|
||||
updated = [_user_to_dict(u) for u in entity.user_ids.sorted('name')]
|
||||
return _json_response({
|
||||
'success': True,
|
||||
'entity_id': entity.id,
|
||||
'user_ids': entity.user_ids.ids,
|
||||
'items': updated,
|
||||
'total': len(updated),
|
||||
})
|
||||
except Exception as e:
|
||||
code = 403 if isinstance(e, PermissionError) else 500
|
||||
return _error_response(str(e), code)
|
||||
|
||||
@http.route('/api/entities/<int:entity_id>', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
|
||||
@@ -224,7 +224,15 @@ class AcademicController(http.Controller):
|
||||
'term_end_date': str(e),
|
||||
'academic_year_id': year.id,
|
||||
})
|
||||
if 'parent_id' in Term._fields:
|
||||
# Wire the quarter -> parent semester via the actual
|
||||
# field name on op.academic.term, which is
|
||||
# ``parent_term`` (not ``parent_id``). The previous
|
||||
# check matched ``parent_id`` and silently no-op'd
|
||||
# for everyone — the relationship was never written.
|
||||
if 'parent_term' in Term._fields:
|
||||
q1.write({'parent_term': parent.id})
|
||||
q2.write({'parent_term': parent.id})
|
||||
elif 'parent_id' in Term._fields:
|
||||
q1.write({'parent_id': parent.id})
|
||||
q2.write({'parent_id': parent.id})
|
||||
quarters += [q1, q2]
|
||||
|
||||
@@ -11,6 +11,48 @@ _logger = logging.getLogger(__name__)
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for the current user."""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
return ids, is_super
|
||||
|
||||
|
||||
def _ensure_entity_access(entity_id):
|
||||
"""Raise PermissionError if current user cannot access entity_id."""
|
||||
if not entity_id:
|
||||
raise PermissionError('entity_id is required')
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return int(entity_id)
|
||||
if not ids:
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
if int(entity_id) not in ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
return int(entity_id)
|
||||
|
||||
|
||||
def _default_entity_id_from_scope():
|
||||
"""Pick the default entity for creation from the current user scope."""
|
||||
ids, is_super = _entity_scope()
|
||||
if ids:
|
||||
return ids[0]
|
||||
if is_super:
|
||||
return False
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
|
||||
|
||||
def _scoped_entity_domain(base_domain=None, field='entity_id'):
|
||||
"""Apply entity isolation domain to a model query."""
|
||||
domain = list(base_domain or [])
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [(field, 'in', ids)]
|
||||
|
||||
def _serialize_course(c):
|
||||
subj = getattr(c, 'encoach_subject_id', False)
|
||||
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
|
||||
@@ -43,6 +85,8 @@ def _serialize_course(c):
|
||||
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
|
||||
'resource_count': getattr(c, 'resource_count', 0) or 0,
|
||||
'objective_count': getattr(c, 'objective_count', 0) or 0,
|
||||
'entity_id': c.entity_id.id if hasattr(c, 'entity_id') and c.entity_id else None,
|
||||
'entity_name': c.entity_id.name if hasattr(c, 'entity_id') and c.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +119,8 @@ def _serialize_student(s):
|
||||
'batch_name': batch_name,
|
||||
'partner_id': partner.id,
|
||||
'user_id': s.user_id.id if s.user_id else None,
|
||||
'entity_id': s.entity_id.id if hasattr(s, 'entity_id') and s.entity_id else None,
|
||||
'entity_name': s.entity_id.name if hasattr(s, 'entity_id') and s.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +143,8 @@ def _serialize_teacher(f):
|
||||
'department_name': dept.name if dept else '',
|
||||
'specialization': getattr(f, 'specialization', '') or '',
|
||||
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
|
||||
'entity_id': f.entity_id.id if hasattr(f, 'entity_id') and f.entity_id else None,
|
||||
'entity_name': f.entity_id.name if hasattr(f, 'entity_id') and f.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +175,8 @@ def _serialize_batch(b):
|
||||
'max_students': getattr(b, 'max_students', 0) or 0,
|
||||
'student_count': len(students),
|
||||
'students': students,
|
||||
'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None,
|
||||
'entity_name': b.entity_id.name if hasattr(b, 'entity_id') and b.entity_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -139,9 +189,11 @@ class LmsCoreController(http.Controller):
|
||||
def list_courses(self, **kw):
|
||||
try:
|
||||
Course = request.env['op.course'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('status'):
|
||||
pass # op.course has no status field by default
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Course.search_count(domain)
|
||||
records = Course.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -154,7 +206,7 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_courses failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -163,6 +215,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.course'].sudo().browse(course_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_course failed')
|
||||
@@ -173,10 +228,17 @@ class LmsCoreController(http.Controller):
|
||||
def create_course(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
vals = {
|
||||
'name': body.get('name', ''),
|
||||
'code': body.get('code', ''),
|
||||
}
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('description'):
|
||||
vals['description'] = body['description']
|
||||
if body.get('max_capacity'):
|
||||
@@ -197,7 +259,7 @@ class LmsCoreController(http.Controller):
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_course failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -206,6 +268,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.course'].sudo().browse(course_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
for k in ('name', 'code', 'description'):
|
||||
@@ -225,12 +290,14 @@ class LmsCoreController(http.Controller):
|
||||
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
|
||||
if 'tag_ids' in body:
|
||||
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_course failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -239,6 +306,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.course'].sudo().browse(course_id)
|
||||
if not rec.exists():
|
||||
return _json_response({'success': True})
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes')
|
||||
enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)])
|
||||
if enrollments and not force:
|
||||
@@ -272,8 +342,12 @@ class LmsCoreController(http.Controller):
|
||||
('student_id', '=', student.id)
|
||||
])
|
||||
course_ids = course_details.mapped('course_id')
|
||||
ids, is_super = _entity_scope()
|
||||
items = []
|
||||
for c in course_ids:
|
||||
if not is_super:
|
||||
if not c.entity_id or c.entity_id.id not in ids:
|
||||
continue
|
||||
data = _serialize_course(c)
|
||||
cd = course_details.filtered(lambda d: d.course_id.id == c.id)
|
||||
data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None
|
||||
@@ -307,6 +381,9 @@ class LmsCoreController(http.Controller):
|
||||
student = request.env['op.student'].sudo().browse(student_id)
|
||||
if not student.exists():
|
||||
return _error_response('Student not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not student.entity_id or student.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
course_id = body.get('course_id')
|
||||
course_ids = body.get('course_ids', [])
|
||||
@@ -317,6 +394,13 @@ class LmsCoreController(http.Controller):
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
for cid in course_ids:
|
||||
cid = int(cid)
|
||||
course = request.env['op.course'].sudo().browse(cid)
|
||||
if not course.exists():
|
||||
continue
|
||||
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
|
||||
continue
|
||||
if student.entity_id and course.entity_id and student.entity_id.id != course.entity_id.id:
|
||||
continue
|
||||
existing = SC.search([
|
||||
('student_id', '=', student.id),
|
||||
('course_id', '=', cid),
|
||||
@@ -347,6 +431,9 @@ class LmsCoreController(http.Controller):
|
||||
course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
student_ids = [int(sid) for sid in body.get('student_ids', [])]
|
||||
batch_id = body.get('batch_id')
|
||||
@@ -358,6 +445,13 @@ class LmsCoreController(http.Controller):
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
enrolled = []
|
||||
for sid in student_ids:
|
||||
stu = request.env['op.student'].sudo().browse(sid)
|
||||
if not stu.exists():
|
||||
continue
|
||||
if not is_super and (not stu.entity_id or stu.entity_id.id not in ids):
|
||||
continue
|
||||
if stu.entity_id and course.entity_id and stu.entity_id.id != course.entity_id.id:
|
||||
continue
|
||||
existing = SC.search([
|
||||
('student_id', '=', sid),
|
||||
('course_id', '=', course_id),
|
||||
@@ -385,11 +479,13 @@ class LmsCoreController(http.Controller):
|
||||
def list_students(self, **kw):
|
||||
try:
|
||||
Student = request.env['op.student'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('search'):
|
||||
domain = [('partner_id.name', 'ilike', kw['search'])]
|
||||
domain.append(('partner_id.name', 'ilike', kw['search']))
|
||||
if kw.get('batch_id'):
|
||||
domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Student.search_count(domain)
|
||||
records = Student.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -402,7 +498,7 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_students failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -411,6 +507,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.student'].sudo().browse(student_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_student(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_student failed')
|
||||
@@ -421,6 +520,11 @@ class LmsCoreController(http.Controller):
|
||||
def create_student(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
first = body.get('first_name', '')
|
||||
last = body.get('last_name', '')
|
||||
name = f"{first} {last}".strip()
|
||||
@@ -434,6 +538,8 @@ class LmsCoreController(http.Controller):
|
||||
'partner_id': partner.id,
|
||||
'gender': body.get('gender', ''),
|
||||
}
|
||||
if entity_id:
|
||||
student_vals['entity_id'] = entity_id
|
||||
if body.get('birth_date'):
|
||||
student_vals['birth_date'] = body['birth_date']
|
||||
student = request.env['op.student'].sudo().create(student_vals)
|
||||
@@ -453,11 +559,13 @@ class LmsCoreController(http.Controller):
|
||||
'password': body.get('password', 'student123'),
|
||||
'partner_id': partner.id,
|
||||
})
|
||||
if entity_id and hasattr(user, 'entity_ids'):
|
||||
user.write({'entity_ids': [(4, entity_id)]})
|
||||
student.sudo().write({'user_id': user.id})
|
||||
return _json_response({'data': _serialize_student(student)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_student failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -466,6 +574,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.student'].sudo().browse(student_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
partner_vals = {}
|
||||
if 'first_name' in body or 'last_name' in body:
|
||||
@@ -481,12 +592,14 @@ class LmsCoreController(http.Controller):
|
||||
student_vals = {}
|
||||
if 'gender' in body:
|
||||
student_vals['gender'] = body['gender']
|
||||
if 'entity_id' in body:
|
||||
student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if student_vals:
|
||||
rec.write(student_vals)
|
||||
return _json_response({'data': _serialize_student(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_student failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -494,6 +607,9 @@ class LmsCoreController(http.Controller):
|
||||
try:
|
||||
rec = request.env['op.student'].sudo().browse(student_id)
|
||||
if rec.exists():
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -507,9 +623,11 @@ class LmsCoreController(http.Controller):
|
||||
def list_teachers(self, **kw):
|
||||
try:
|
||||
Faculty = request.env['op.faculty'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('search'):
|
||||
domain = [('partner_id.name', 'ilike', kw['search'])]
|
||||
domain.append(('partner_id.name', 'ilike', kw['search']))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Faculty.search_count(domain)
|
||||
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -522,13 +640,18 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_teachers failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_teacher(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
first = body.get('first_name', '')
|
||||
last = body.get('last_name', '')
|
||||
name = f"{first} {last}".strip()
|
||||
@@ -541,6 +664,8 @@ class LmsCoreController(http.Controller):
|
||||
'partner_id': partner.id,
|
||||
'gender': body.get('gender', ''),
|
||||
}
|
||||
if entity_id:
|
||||
fac_vals['entity_id'] = entity_id
|
||||
if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
|
||||
fac_vals['department_id'] = int(body['department_id'])
|
||||
if body.get('birth_date'):
|
||||
@@ -549,14 +674,64 @@ class LmsCoreController(http.Controller):
|
||||
return _json_response({'data': _serialize_teacher(faculty)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_teacher failed')
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_teacher(self, teacher_id, **kw):
|
||||
try:
|
||||
rec = request.env['op.faculty'].sudo().browse(teacher_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_teacher(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_teacher failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_teacher(self, teacher_id, **kw):
|
||||
try:
|
||||
rec = request.env['op.faculty'].sudo().browse(teacher_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
pvals = {}
|
||||
if 'name' in body:
|
||||
pvals['name'] = body['name']
|
||||
if 'email' in body:
|
||||
pvals['email'] = body['email']
|
||||
if 'phone' in body:
|
||||
pvals['phone'] = body['phone']
|
||||
if pvals:
|
||||
rec.partner_id.sudo().write(pvals)
|
||||
vals = {}
|
||||
if 'gender' in body:
|
||||
vals['gender'] = body['gender']
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_teacher(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_teacher failed')
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_teacher(self, teacher_id, **kw):
|
||||
try:
|
||||
rec = request.env['op.faculty'].sudo().browse(teacher_id)
|
||||
if rec.exists():
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -570,7 +745,9 @@ class LmsCoreController(http.Controller):
|
||||
def list_batches(self, **kw):
|
||||
try:
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
domain = []
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Batch.search_count(domain)
|
||||
records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -583,7 +760,7 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_batches failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -592,6 +769,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('get_batch failed')
|
||||
@@ -602,11 +782,18 @@ class LmsCoreController(http.Controller):
|
||||
def create_batch(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
requested_entity = body.get('entity_id')
|
||||
if requested_entity:
|
||||
entity_id = _ensure_entity_access(int(requested_entity))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
vals = {'name': body.get('name', '')}
|
||||
if body.get('code'):
|
||||
vals['code'] = body['code']
|
||||
if body.get('course_id'):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('start_date'):
|
||||
vals['start_date'] = body['start_date']
|
||||
if body.get('end_date'):
|
||||
@@ -615,7 +802,7 @@ class LmsCoreController(http.Controller):
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_batch failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -624,6 +811,9 @@ class LmsCoreController(http.Controller):
|
||||
rec = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
for k in ('name', 'code', 'start_date', 'end_date'):
|
||||
@@ -631,12 +821,14 @@ class LmsCoreController(http.Controller):
|
||||
vals[k] = body[k]
|
||||
if 'course_id' in body:
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_batch failed')
|
||||
return _error_response(str(e), 500)
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -644,6 +836,9 @@ class LmsCoreController(http.Controller):
|
||||
try:
|
||||
rec = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if rec.exists():
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -655,6 +850,12 @@ class LmsCoreController(http.Controller):
|
||||
def list_batch_students(self, batch_id, **kw):
|
||||
try:
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
recs = SC.search([('batch_id', '=', batch_id)])
|
||||
students = []
|
||||
for sc in recs:
|
||||
@@ -680,11 +881,19 @@ class LmsCoreController(http.Controller):
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
student_ids = [int(s) for s in body.get('student_ids', [])]
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
added = []
|
||||
for sid in student_ids:
|
||||
stu = request.env['op.student'].sudo().browse(sid)
|
||||
if not stu.exists():
|
||||
continue
|
||||
if batch.entity_id and stu.entity_id and batch.entity_id.id != stu.entity_id.id:
|
||||
continue
|
||||
existing = SC.search([
|
||||
('student_id', '=', sid),
|
||||
('batch_id', '=', batch_id),
|
||||
@@ -719,6 +928,12 @@ class LmsCoreController(http.Controller):
|
||||
def remove_students_from_batch(self, batch_id, **kw):
|
||||
"""Remove students from a batch by clearing their batch_id."""
|
||||
try:
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
student_ids = [int(s) for s in body.get('student_ids', [])]
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
@@ -751,6 +966,9 @@ class LmsCoreController(http.Controller):
|
||||
course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False
|
||||
topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic']
|
||||
objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective']
|
||||
@@ -771,9 +989,9 @@ class LmsCoreController(http.Controller):
|
||||
def subject_courses(self, subject_id, **kw):
|
||||
"""Return all courses linked to a given taxonomy subject."""
|
||||
try:
|
||||
courses = request.env['op.course'].sudo().search([
|
||||
courses = request.env['op.course'].sudo().search(_scoped_entity_domain([
|
||||
('encoach_subject_id', '=', subject_id)
|
||||
])
|
||||
]))
|
||||
return _json_response({
|
||||
'items': [_serialize_course(c) for c in courses],
|
||||
'total': len(courses),
|
||||
|
||||
@@ -63,17 +63,70 @@ def _attempt_completed_at(att):
|
||||
return None
|
||||
|
||||
|
||||
def _allowed_entity_ids(env):
|
||||
"""Return the set of entity ids the calling user is allowed to see.
|
||||
|
||||
* Admins / system users: ``None`` (unrestricted — they may pass any
|
||||
``entity_id`` query param to scope manually).
|
||||
* Corporate / master-corporate / teacher / student: the entity ids
|
||||
linked to ``res.users.entity_ids`` on their record. Empty set means
|
||||
"no entities, see nothing" (defensive — better than leaking).
|
||||
"""
|
||||
user = env.user
|
||||
if not user or not user.id:
|
||||
return set()
|
||||
user_type = getattr(user, 'user_type', None)
|
||||
if user_type == 'admin' or user.has_group('base.group_system'):
|
||||
return None # unrestricted
|
||||
try:
|
||||
return set((user.entity_ids or env['encoach.entity']).ids)
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _build_attempt_domain(kw, reportable=True):
|
||||
"""Common filter reader used by all three endpoints."""
|
||||
"""Common filter reader used by all three endpoints.
|
||||
|
||||
Always enforces the caller's allowed entity scope as a default
|
||||
domain so corporate users can never see another company's data —
|
||||
even if they omit ``entity_id`` or pass one outside their allow-list.
|
||||
Admins are unrestricted and may pass any ``entity_id``.
|
||||
"""
|
||||
from odoo.http import request as _req
|
||||
domain = []
|
||||
if reportable:
|
||||
domain.append(('status', 'in', list(REPORTABLE_STATUSES)))
|
||||
entity_id = kw.get('entity_id')
|
||||
if entity_id:
|
||||
|
||||
allowed = _allowed_entity_ids(_req.env)
|
||||
requested_entity = kw.get('entity_id')
|
||||
requested_int = None
|
||||
if requested_entity:
|
||||
try:
|
||||
domain.append(('entity_id', '=', int(entity_id)))
|
||||
requested_int = int(requested_entity)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
requested_int = None
|
||||
|
||||
if allowed is None:
|
||||
# Admin: honour the requested entity_id verbatim, no scoping.
|
||||
if requested_int is not None:
|
||||
domain.append(('entity_id', '=', requested_int))
|
||||
else:
|
||||
# Non-admin: clamp the requested entity to the allow-list. If
|
||||
# they didn't request one, scope to all of theirs. If their
|
||||
# request is outside the allow-list, return an empty result set
|
||||
# by appending an impossible domain — never a 200 with leaked
|
||||
# data.
|
||||
if requested_int is not None:
|
||||
if requested_int in allowed:
|
||||
domain.append(('entity_id', '=', requested_int))
|
||||
else:
|
||||
domain.append(('entity_id', '=', -1)) # forces empty
|
||||
else:
|
||||
if allowed:
|
||||
domain.append(('entity_id', 'in', list(allowed)))
|
||||
else:
|
||||
domain.append(('entity_id', '=', -1)) # no entities, no data
|
||||
|
||||
user_id = kw.get('user_id') or kw.get('student_id')
|
||||
if user_id:
|
||||
try:
|
||||
|
||||
@@ -1,17 +1,90 @@
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
|
||||
validate_token,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Keep this in sync with the Selection on ``encoach.resource`` so we
|
||||
# never silently drop a category. Order matters: more specific MIMEs
|
||||
# (``application/pdf``) must come before catch-all groups (``image/*``)
|
||||
# because the matcher walks the list top-to-bottom.
|
||||
_MIME_TO_TYPE = (
|
||||
('application/pdf', 'pdf'),
|
||||
('image/', 'image'),
|
||||
('audio/', 'audio'),
|
||||
('video/', 'video'),
|
||||
('text/html', 'article'),
|
||||
('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document'),
|
||||
('application/msword', 'document'),
|
||||
('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'document'),
|
||||
('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'document'),
|
||||
('text/', 'document'),
|
||||
)
|
||||
|
||||
|
||||
def _detect_type_from_mime(mime):
|
||||
"""Map a MIME string to one of the ``encoach.resource.type`` values."""
|
||||
if not mime:
|
||||
return ''
|
||||
mime = mime.lower().split(';')[0].strip()
|
||||
for prefix, rtype in _MIME_TO_TYPE:
|
||||
if mime.startswith(prefix):
|
||||
return rtype
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_attachment_mime(rec):
|
||||
"""Find the MIME of the binary even when ``rec.mimetype`` was never
|
||||
persisted (older rows uploaded before the schema migration). We
|
||||
walk the ir.attachment row Odoo creates for ``Binary(attachment=True)``
|
||||
and fall back to extension sniffing on the human name.
|
||||
"""
|
||||
if rec.mimetype:
|
||||
return rec.mimetype.split(';')[0].strip().lower()
|
||||
att = rec.env['ir.attachment'].sudo().search([
|
||||
('res_model', '=', 'encoach.resource'),
|
||||
('res_id', '=', rec.id),
|
||||
('res_field', '=', 'file'),
|
||||
], limit=1)
|
||||
if att and att.mimetype:
|
||||
return att.mimetype.split(';')[0].strip().lower()
|
||||
if rec.name:
|
||||
return (mimetypes.guess_type(rec.name)[0] or '').lower()
|
||||
return ''
|
||||
|
||||
|
||||
def _build_filename(rec):
|
||||
"""Best-effort filename with extension for the download header.
|
||||
|
||||
Priority: 1) the persisted original_filename (always has the
|
||||
extension as uploaded), 2) the human name + an extension guessed
|
||||
from the cached mimetype (or sniffed from the linked ir.attachment),
|
||||
3) the human name as-is.
|
||||
"""
|
||||
if rec.original_filename:
|
||||
return rec.original_filename
|
||||
base = (rec.name or f'resource-{rec.id}').strip()
|
||||
if '.' in os.path.basename(base):
|
||||
return base
|
||||
mime = _resolve_attachment_mime(rec)
|
||||
ext = mimetypes.guess_extension(mime) if mime else ''
|
||||
return f'{base}{ext}' if ext else base
|
||||
|
||||
|
||||
def _ser_resource(r):
|
||||
tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag']
|
||||
objectives = r.learning_objective_ids if r.learning_objective_ids else r.env['encoach.learning.objective']
|
||||
download_url = f'/api/resources/{r.id}/download' if r.file else ''
|
||||
preview_url = f'/api/resources/{r.id}/download?inline=1' if r.file else ''
|
||||
return {
|
||||
'id': r.id,
|
||||
'name': r.name or '',
|
||||
@@ -30,6 +103,11 @@ def _ser_resource(r):
|
||||
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
|
||||
'url': r.url or '',
|
||||
'has_file': bool(r.file),
|
||||
'mimetype': r.mimetype or '',
|
||||
'original_filename': r.original_filename or '',
|
||||
'download_url': download_url,
|
||||
'preview_url': preview_url,
|
||||
'file_name': r.original_filename or r.name or '',
|
||||
'difficulty': r.difficulty or '',
|
||||
'duration_minutes': r.duration_minutes or 0,
|
||||
'author_id': r.creator_id.id if r.creator_id else None,
|
||||
@@ -125,7 +203,36 @@ class ResourcesController(http.Controller):
|
||||
if params.get('cefr_level'):
|
||||
vals['cefr_level'] = params['cefr_level']
|
||||
if f:
|
||||
vals['file'] = base64.b64encode(f.read())
|
||||
payload = f.read()
|
||||
vals['file'] = base64.b64encode(payload)
|
||||
# Persist the *real* upload filename — the human-
|
||||
# readable ``name`` field often loses the extension
|
||||
# ("test" instead of "test.pdf"), which broke
|
||||
# downloads and inline previews.
|
||||
if f.filename:
|
||||
vals['original_filename'] = f.filename
|
||||
# Detect MIME — prefer the one Werkzeug parsed from
|
||||
# the multipart upload; fall back to extension sniffing
|
||||
# for clients that don't send it.
|
||||
mime = (f.mimetype or '').split(';')[0].strip().lower()
|
||||
if not mime and f.filename:
|
||||
mime = (mimetypes.guess_type(f.filename)[0] or '').lower()
|
||||
if mime:
|
||||
vals['mimetype'] = mime
|
||||
# Auto-correct the type when the user picked the wrong
|
||||
# one in the dropdown (e.g. PDF default but actually an
|
||||
# image), or when no type was supplied at all. We only
|
||||
# *override* an explicit user choice when the picked
|
||||
# type clearly contradicts the mime — otherwise keep
|
||||
# what the admin selected.
|
||||
detected = _detect_type_from_mime(mime)
|
||||
if detected:
|
||||
if not vals.get('type'):
|
||||
vals['type'] = detected
|
||||
elif vals['type'] in ('pdf', 'image', 'audio', 'video') \
|
||||
and vals['type'] != detected \
|
||||
and detected in ('pdf', 'image', 'audio', 'video'):
|
||||
vals['type'] = detected
|
||||
rec = request.env['encoach.resource'].sudo().create(vals)
|
||||
return _json_response({'data': _ser_resource(rec)})
|
||||
except Exception as e:
|
||||
@@ -195,21 +302,49 @@ class ResourcesController(http.Controller):
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/resources/<int:rid>/download', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
# ``auth='none'`` + manual JWT validation lets us accept the token
|
||||
# from either the ``Authorization: Bearer`` header (download anchor
|
||||
# via fetch) **or** the ``?token=`` query param (preview iframe /
|
||||
# <img> / <audio>). HTML media tags can't send custom headers, so
|
||||
# the query-param fallback is what makes inline preview work.
|
||||
@http.route('/api/resources/<int:rid>/download', type='http',
|
||||
auth='none', methods=['GET'], csrf=False)
|
||||
def download_resource(self, rid, **kw):
|
||||
try:
|
||||
user = validate_token(allow_query_param=True)
|
||||
if not user:
|
||||
return _error_response('Authentication required', 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
rec = request.env['encoach.resource'].sudo().browse(rid)
|
||||
if not rec.exists() or not rec.file:
|
||||
return _error_response('No file', 404)
|
||||
import mimetypes
|
||||
ext = (rec.name or '').rsplit('.', 1)[-1].lower() if '.' in (rec.name or '') else ''
|
||||
mime = mimetypes.types_map.get(f'.{ext}', 'application/octet-stream')
|
||||
|
||||
data = base64.b64decode(rec.file)
|
||||
|
||||
filename = _build_filename(rec)
|
||||
mime = (
|
||||
_resolve_attachment_mime(rec)
|
||||
or mimetypes.guess_type(filename)[0]
|
||||
or 'application/octet-stream'
|
||||
)
|
||||
|
||||
# ``inline=1`` (or ``preview=1``) lets the browser render
|
||||
# PDFs in an iframe / images in <img> instead of forcing a
|
||||
# download. Default stays ``attachment`` for safety so
|
||||
# existing /download links keep their old behaviour.
|
||||
inline_flag = (
|
||||
request.httprequest.args.get('inline')
|
||||
or request.httprequest.args.get('preview')
|
||||
or ''
|
||||
).lower() in ('1', 'true', 'yes')
|
||||
disposition_kind = 'inline' if inline_flag else 'attachment'
|
||||
|
||||
return request.make_response(data, [
|
||||
('Content-Type', mime),
|
||||
('Content-Disposition', f'attachment; filename="{rec.name}"'),
|
||||
('Content-Disposition', f'{disposition_kind}; filename="{filename}"'),
|
||||
('Content-Length', str(len(data))),
|
||||
('Cache-Control', 'private, max-age=3600'),
|
||||
])
|
||||
except Exception as e:
|
||||
_logger.exception('download_resource')
|
||||
|
||||
@@ -6,6 +6,13 @@ class OpCourseExt(models.Model):
|
||||
|
||||
description = fields.Text('Description')
|
||||
max_capacity = fields.Integer('Max Capacity', default=30)
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
encoach_subject_id = fields.Many2one(
|
||||
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
|
||||
@@ -54,3 +61,39 @@ class OpCourseExt(models.Model):
|
||||
('resource_id', '!=', False),
|
||||
])
|
||||
rec.resource_count = len(mats.mapped('resource_id'))
|
||||
|
||||
|
||||
class OpBatchExt(models.Model):
|
||||
_inherit = 'op.batch'
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
|
||||
class OpStudentExt(models.Model):
|
||||
_inherit = 'op.student'
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
|
||||
class OpFacultyExt(models.Model):
|
||||
_inherit = 'op.faculty'
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
|
||||
@@ -12,6 +12,13 @@ class EncoachResource(models.Model):
|
||||
('document', 'Document'),
|
||||
('link', 'Link'),
|
||||
('interactive', 'Interactive'),
|
||||
# Audio + image are surfaced in the Resource Manager table
|
||||
# (icons / filters) so we accept them as first-class types
|
||||
# rather than coercing them into ``document`` and losing the
|
||||
# MIME-correct preview (audio player, <img>, etc.).
|
||||
('audio', 'Audio'),
|
||||
('image', 'Image'),
|
||||
('article', 'Article'),
|
||||
])
|
||||
review_status = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
@@ -30,6 +37,24 @@ class EncoachResource(models.Model):
|
||||
'resource_id', 'tag_id', string='Tags',
|
||||
)
|
||||
file = fields.Binary(attachment=True)
|
||||
# Preserve the original upload filename (with extension) so the
|
||||
# download endpoint can serve "report.pdf" even when the human-
|
||||
# readable ``name`` field is set to "Q3 Sales Report" without an
|
||||
# extension. We also keep the resolved MIME type to avoid sniffing
|
||||
# by extension on every download — sniffing fails on resources
|
||||
# named "test" with no dot, and was the root cause of admins
|
||||
# downloading files with no extension and ``application/octet-stream``
|
||||
# ending up unviewable on macOS / Windows.
|
||||
original_filename = fields.Char(
|
||||
string='Original filename',
|
||||
help='Filename as uploaded, including extension. Used by the '
|
||||
'download endpoint to produce a sensible Content-Disposition.',
|
||||
)
|
||||
mimetype = fields.Char(
|
||||
string='MIME type',
|
||||
help='Cached MIME type from the uploaded file or URL. Drives '
|
||||
'preview decisions (PDF in iframe vs <img> vs <video>).',
|
||||
)
|
||||
url = fields.Char()
|
||||
difficulty = fields.Selection([
|
||||
('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'),
|
||||
@@ -67,6 +92,16 @@ class EncoachResource(models.Model):
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
creator = self.creator_id
|
||||
# Both URLs are JWT-protected via the resources controller; the
|
||||
# frontend appends ``?token=…`` with the existing
|
||||
# ``withAuthQuery`` helper so they can be used as ``href``,
|
||||
# ``src`` of <iframe>/<img>, or download anchors.
|
||||
download_url = (
|
||||
f'/api/resources/{self.id}/download' if self.file else ''
|
||||
)
|
||||
preview_url = (
|
||||
f'/api/resources/{self.id}/download?inline=1' if self.file else ''
|
||||
)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
@@ -82,6 +117,10 @@ class EncoachResource(models.Model):
|
||||
'learning_objective_names': self.learning_objective_ids.mapped('name'),
|
||||
'url': self.url or '',
|
||||
'has_file': bool(self.file),
|
||||
'mimetype': self.mimetype or '',
|
||||
'original_filename': self.original_filename or '',
|
||||
'download_url': download_url,
|
||||
'preview_url': preview_url,
|
||||
'difficulty': self.difficulty or '',
|
||||
'duration_minutes': self.duration_minutes,
|
||||
'author_id': creator.id if creator else None,
|
||||
@@ -98,5 +137,5 @@ class EncoachResource(models.Model):
|
||||
'approved': self.approved,
|
||||
'course_count': self.course_count,
|
||||
'created_at': self.create_date.isoformat() if self.create_date else '',
|
||||
'file_name': self.name,
|
||||
'file_name': self.original_filename or self.name,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class EncoachEmbedding(models.Model):
|
||||
('feedback', 'Feedback'),
|
||||
('generation_log', 'Generation Log'),
|
||||
('material', 'Course Material'),
|
||||
('course_plan_source', 'Course Plan Source'),
|
||||
], required=True, index=True)
|
||||
content_id = fields.Integer(required=True, index=True)
|
||||
content_text = fields.Text()
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# EnCoach Platform — Project Summary
|
||||
|
||||
> Last updated: 2026-04-25 | **Canonical repos: [`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4) (backend) + [`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4) (frontend), branch `main`.**
|
||||
> Last updated: 2026-04-27 | **Canonical repos: [`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4) (backend) + [`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4) (frontend), branch `main`.**
|
||||
>
|
||||
> This workspace (`odoo19/`) is a **developer monorepo / working tree only** — it conveniently contains both halves side-by-side for local development and testing. The two split repos above are the **authoritative origins** for each half: every change must be published to them (via `git subtree split + push`) before the team lead can deploy. See **§6 Git Remotes & Repositories** for the exact workflow.
|
||||
|
||||
> **Latest events:**
|
||||
> - **2026-04-27 (professional interactive course plans + scanned-PDF OCR indexing):** Completed the end-to-end "Professional Interactive Course Plans" rollout and validated it in API smoke + browser runs. Backend: added `RAGContextBuilder`, richer v2 week-material schema (`/api/ai/course-plan/<id>/weeks/<n>/materials/v2`), `interactive_workbook` material type, provenance fields (`grounded_on_json`, `extracted_from_json`), workbook extraction service, and attempt persistence/scoring model (`encoach.course.plan.workbook.attempt`) with new endpoints for extraction, grounding, and attempts. Frontend: added `InteractiveWorkbook`, shared `PlanReader`, `GroundingBadge`, and true admin "View as Student" preview mode rendering the same student reader without requiring a student login. Operational fix: scanned exercise books (e.g. Headway Intermediate workbook) were failing with `Extracted no text from source`; implemented OCR fallback in `source_indexer.py` (tesseract + poppler + `pdf2image`/`pytesseract`) with per-page streaming to keep memory bounded, then re-indexed failed sources successfully (`indexed`, 109 chunks, ~207k chars each). Regression checks still pass (`smoke_assignment_workflow.py`, `smoke_entity_isolation.py`, `smoke_course_plan_rag.py`) and browser verification confirms sources now show green `Indexed` badges. **Current environment note:** OpenAI course-generation calls are returning `429 insufficient_quota`, so "Regenerate week materials" currently writes the intended skeleton fallback content with an explicit in-body note until API billing/quota is restored.
|
||||
> - **2026-04-26 (dynamic course-section UX completion):** Wired the dynamic `Course → Sections → Classroom → Batch` structure all the way through the admin UX. New idempotent backend endpoint `POST /api/courses/<id>/sections/generate-defaults` creates the canonical **A/B/C** templates in one call (custom codes also supported via `{ "codes": [...] }`). `POST /api/batches` now auto-generates a unique `code` (built from course + section + term) when the client omits one — fixing the previous NOT-NULL/uniqueness failure on `op.batch.code` and unblocking the AdminBatches form flow. New frontend service helpers `generateDefaultCourseSections`, `updateCourseSection`, `deleteCourseSection`. `AdminCourses` now shows a **Sections** column with the live count + section codes + a “Manage Sections” dialog (list / add / inline edit / delete + one-click `Generate A · B · C`). `AdminBatches` exposes `Section` + `Term Key` columns plus matching selectors in both Create and Edit dialogs (the section list is fetched per-course and disabled until a course is picked). Smoke-tested live: generate-defaults created A/B/C (idempotent re-run added only the new `D`), section PATCH/DELETE worked, batch was created/updated/validated against course mismatch (`400 course_section_id does not belong to course_id`), and the Edit dialog hydrated `course_section_id` + `term_key` cleanly. Combined with the previous classroom-side cascade, the platform now fully implements the diagram: any classroom can host any sections from any courses, each producing exactly one canonical batch per `(classroom × course × section × term)` with roster + teachers auto-propagated.
|
||||
> - **2026-04-26 (dynamic course-section classroom structure):** Implemented a dynamic **Course -> Sections -> Classroom hosting** model aligned to the LMS diagrams. Added new backend model `encoach.course.section` (per-course section templates, unique code per course, optional branch) with API CRUD routes in `encoach_lms_api/controllers/lms_core.py`: `GET/POST /api/courses/<course_id>/sections`, `PATCH/DELETE /api/courses/<course_id>/sections/<section_id>`. Extended batches with `course_section_id` + `term_key`, and classroom cascade assignment now supports `section_id`/`term_key` to create or reuse canonical batches per `(classroom × course × section × term)`. Added explicit alias route `POST /api/classrooms/<id>/assign-section` and new `GET /api/classrooms/<id>/sections`. Frontend types/services/pages updated accordingly: course section types, section-aware classroom assignment flow, and batches UI now shows section metadata. Smoke-tested successfully: same classroom received **Section A** and **Section B** of the same course, producing two distinct batches with roster auto-propagation.
|
||||
> - **2026-04-26 (admin-only entity membership management UI):** Added end-to-end UI flow for linking users to entities directly from `Admin -> Entities` (admin only). New backend routes in `encoach_exam_template/controllers/entities.py`: `GET /api/entities/<entity_id>/users` and `PATCH /api/entities/<entity_id>/users` (payload `{ user_ids: [...] }`) with strict admin guard (`base.group_system` / `base.group_erp_manager` / `user_type=admin`). `EntitiesPage` now includes a **Manage Users** action (`UserCog`) opening a searchable multi-select dialog, and `entities.service.ts` now exposes `listEntityUsers`, `updateEntityUsers`, and `listPlatformUsers` helpers. Verified on live `:8069`: admin gets `200`, non-admin gets `403`, and `/api/user` reflects newly linked entities after re-login so the header switcher shows them.
|
||||
> - **2026-04-26 (frontend entity switcher + automatic scope propagation):** Added active-entity selection to the authenticated frontend session. `AuthContext` now tracks `selectedEntityId`/`selectedEntity`, persists it in local storage (`encoach_entity_id`), auto-validates it against the logged-in user’s entity memberships, and resets it on logout. `AdminLmsLayout` now exposes an entity switcher in the header (for users with entities) and refreshes route data after switching. `api-client` now auto-injects `entity_id` for entity-scoped LMS endpoints (`/courses`, `/students`, `/teachers`, `/batches`, `/student/my-courses`) in query params and request bodies when the caller didn’t pass one explicitly. This complements the backend guardrails so managers can intentionally switch between isolated entity LMS contexts from UI while backend remains the source of truth.
|
||||
> - **2026-04-26 (entity-isolated LMS backend guardrails):** Implemented server-side entity isolation for core LMS APIs so users only see/manage records within their linked entities. Added `entity_id` ownership fields to `op.batch`, `op.student`, `op.faculty` (and kept `op.course.entity_id` in this module extension), then enforced scoped domains + access checks in `encoach_lms_api/controllers/lms_core.py` across courses/students/teachers/batches and enrollment flows. Creation now auto-defaults to the caller’s first entity when `entity_id` is omitted (non-super users), supports explicit `entity_id` with access validation, and returns **403** on cross-entity access attempts. Serializers now expose `entity_id`/`entity_name` for these LMS records. Verified locally after module upgrade (`-u encoach_lms_api`): non-super user with entity `{1}` sees only entity `1` courses and gets `403` for `?entity_id=3`.
|
||||
> - **2026-04-26 (course-plan material authoring + entity assignment):** Upgraded AI course-plan delivery UX from raw JSON to an editable, book-style material experience. Added `PATCH /api/ai/course-plan/material/<id>` to edit generated material title/summary/body text, plus new metadata fields on `encoach.course.plan.material`: `share_date` (editable) and `is_static` (preserved on regenerate). Week regeneration now keeps static rows (`generate_week_materials` only replaces non-static materials). Added multi-entity assignment support with new assignment mode `entities` (`entity_ids`), including API create/list payloads and student visibility expansion via entity membership. Admin detail now supports student-preview mode, per-skill color badges + filtering, simple content editing (not code view), share-date/static controls, and entity picker in assignment dialog. Student detail now renders the same book-style material view with per-skill filters and authenticated media preview URLs. Module upgraded locally with `-u encoach_ai_course`; API smoke verified: material patch + entity assignment both pass.
|
||||
> - **2026-04-25 (full demo seed + 8-role E2E):** Filled every product `user_type` with believable demo data and ran end-to-end smoke + mutation tests across all eight roles. New idempotent seeders (`seed_full_demo.py`, `reset_demo_passwords.py`) add the 5 missing user types (`approver`, `corporate`, `mastercorporate`, `agent`, `developer`), an active 2-stage exam-approval workflow with one pending request, and a full **GE1-aligned B1 course plan** modelled on the UTAS *General English 1 Fall AY25-26* outline (12 weeks, 6 detailed week-1 materials covering reading / writing / listening / speaking / grammar / vocabulary). New `e2e_full_scenario.py` exercises the API surface for each role (**46/46 PASS, 0 fail** across admin/teacher/approver/student/corporate/mastercorporate/agent/developer) and `e2e_approval_chain.py` walks the full mutation path: approver approves stage 1 → admin approves stage 2 → linked exam auto-published. Live LangGraph round-trips verified during the run (writing_grader 3.3 s, lms_tutor ReAct with 2 real tool calls 13 s). Full QA write-up in `docs/ENCOACH_FULL_DEMO_QA_REPORT.md`. See §23.
|
||||
> - **2026-04-25 (LangGraph as core AI runtime):** Made LangGraph the backbone for every AI feature on the platform — course planning, exam/exercise generation, LMS tutor, writing/speaking grading. New `encoach.ai.agent` + `encoach.ai.tool` Odoo models (M2M tool binding, graph type, model, temperature, fallback model, max revisions, quality checks, system prompt, prompt key, response format). New `services/agent_runtime.py` compiles each agent into a `StateGraph` with four topologies (`simple`, `plan_review_revise`, `rag`, `react`) and `services/agent_tools.py` ships an 11-tool registry wrapping existing services (vector search, rubric/outcomes/student fetch, CEFR/AI-detect/content-gate, course-plan persistence, writing/speaking grading). 7 default agents seeded via `data/agents_defaults.xml`. New `/api/ai/agents*` controller (list/get/update/test, list-tools, toggle-tool). The page at `/admin/ai/prompts` is now a tabbed **Agents | Tools | Prompts** console with a config dialog (graph type, model, temperature, fallback, max revisions, quality checks, tool toggles) and a built-in Test Runner that shows output + tool trace + retrieval hits + revisions + quality issues. EN + AR (RTL) translations for every new string. The `CoursePlanPipeline` now routes through `AgentRuntime` when `encoach_ai.use_langgraph_runtime` is on. See §22.
|
||||
> - **2026-04-19 (reports section):** Built the Reports section end-to-end — the three pages `/admin/student-performance`, `/admin/stats-corporate`, `/admin/record` (previously pure hardcoded-array mocks) are now wired to real aggregated data from `encoach.student.attempt`. New `/api/reports/{student-performance,stats-corporate,record,filters}` controller (`encoach_lms_api/controllers/reports.py`) does the rollups: per-student band averages + CEFR, per-module corporate charts, trend / distribution / entity comparison, and per-user attempt history with search / level / entity / period filters and CSV export. New `seed_reports.py` completes in-progress attempts and backfills six months of historical attempts so the trend chart and KPI cards are meaningful. 25/25 API smoke passing (`test_reports_flows.py`), 24/24 Configuration + 29/29 Support + 26/26 Training regressions still green, all three pages verified live in-browser with 28 real attempts showing across 4 tabs. See §20.
|
||||
@@ -399,6 +406,8 @@ Stored in macOS Keychain for `git.albousalh.com`:
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| GET | `/api/entities` | List entities |
|
||||
| GET | `/api/entities/<entity_id>/users` | List users assigned to the entity (**admin only**) |
|
||||
| PATCH/PUT | `/api/entities/<entity_id>/users` | Replace assigned entity users with `user_ids` (**admin only**) |
|
||||
|
||||
### LMS Core (`encoach_lms_api` — `lms_core.py`)
|
||||
| Method | Route | Description |
|
||||
@@ -1720,3 +1729,151 @@ docs/ENCOACH_FULL_DEMO_QA_REPORT.md # full QA write-up: credentials, dataset s
|
||||
- `khalid@encoach.test` was failing login because his password had drifted during earlier interactive testing. `reset_demo_passwords.py` now restores all canonical passwords idempotently.
|
||||
- `encoach.ai.log` field names differ from a naive guess — the model uses `service`, `action`, `model_used`, `prompt_tokens`, `completion_tokens`, `total_tokens`, `input_preview`, `output_preview`. `encoach.ai.feedback` uses `subject_type`, `subject_id`, `rating in {'up','down'}` (NOT `'thumbs_up'`). The seeder now matches both definitions.
|
||||
- The exam approval *post-approval* hook only fires when the **final** stage approves, so step 3 above advances the request without publishing the exam yet — that's correct behaviour, just not obvious from the API alone. The DB-side verification in step 6 is what makes it observable.
|
||||
|
||||
## 24. AI Course-Plans — RAG, Multi-modal Media, Assignments (2026-04-25)
|
||||
|
||||
§22 made LangGraph the runtime; §24 puts a real product on top of it. The user asked for an AI-driven **course-plan** experience modelled on the GE1 outline: detect deliverables up-front (e.g. "12 reading texts, 12 listening scripts, 24 audio narrations"), let admins drop reference files / URLs / pasted notes the AI must respect (RAG), generate multi-modal training material (images via DALL-E 3, voice via AWS Polly / ElevenLabs, slideshow video via local `ffmpeg`), then **assign** the finished plan to a class or to specific students. This section is the implementation log.
|
||||
|
||||
### 24.1 What this section delivers
|
||||
|
||||
| Phase | Capability | Where it lives |
|
||||
|---|---|---|
|
||||
| **A** | Reference sources (file/URL/text) → pgvector, scoped to plan | `encoach.course.plan.source` + `services/source_indexer.py` |
|
||||
| **B** | Deliverables preview & live progress strip | `services/deliverables.py` + `/api/ai/course-plan/<id>/deliverables` |
|
||||
| **C** | On-demand audio / image / video per material + bulk-per-week | `encoach.course.plan.media` + `services/media_service.py` |
|
||||
| **D** | Assign a plan to an `op.batch` or specific `res.users` | `encoach.course.plan.assignment` |
|
||||
| **E** | Student-side list + drilldown (read-only) | `/api/student/course-plans*` + `/student/course-plans` page |
|
||||
| **i18n** | Full EN + AR strings for every new control | `frontend/src/i18n/locales/{en,ar}.ts` |
|
||||
|
||||
### 24.2 New / extended backend artefacts
|
||||
|
||||
```text
|
||||
backend/custom_addons/encoach_ai_course/
|
||||
├── models/
|
||||
│ ├── course_plan_source.py ← NEW RAG ingestion row (file|url|text)
|
||||
│ ├── course_plan_media.py ← NEW audio|image|video asset
|
||||
│ └── course_plan_assignment.py ← NEW visibility unit (batch|students)
|
||||
├── services/
|
||||
│ ├── source_indexer.py ← NEW PDF/DOCX/HTML extraction → pgvector
|
||||
│ ├── deliverables.py ← NEW per-week status + percent_ready
|
||||
│ └── media_service.py ← NEW Polly/ElevenLabs/DALL-E/ffmpeg
|
||||
└── controllers/course_plan.py ← +18 endpoints (sources, media, assignments, student-side)
|
||||
```
|
||||
|
||||
Existing models updated:
|
||||
|
||||
* `encoach.course.plan` gains `source_ids`, `media_ids`, `assignment_ids`, `to_api_dict(include_media=...)`.
|
||||
* `encoach.ai.tool.category` selection extends with `('media', 'Media generation')` so the new tools register cleanly.
|
||||
* `encoach.embedding` queries gain a `plan_id` filter — `resources.search` is now plan-scoped when passed `plan_id`, so the agent sees only its own corpus.
|
||||
|
||||
New tools (registered in `encoach_ai/data/agents_defaults.xml`):
|
||||
|
||||
| Tool key | Provider | Notes |
|
||||
|---|---|---|
|
||||
| `media.synthesize_audio` | AWS Polly (default) / ElevenLabs | Used for `listening_script` + `speaking_prompt` |
|
||||
| `media.generate_image` | OpenAI DALL-E 3 | Capped per plan via `encoach_ai_course.image_budget_per_plan` (default 60) |
|
||||
| `media.compose_video` | local `ffmpeg` | Auto-creates audio + image first if missing |
|
||||
|
||||
New agent: **`course_media_director`** — orchestrates per-week media batches; reuses `course_week_materials`'s tools plus the three media tools above. The base `course_week_materials` agent now also has `media.synthesize_audio` + `media.generate_image` in its tool set so a single LangGraph turn can emit the text material *and* its narration/illustration.
|
||||
|
||||
### 24.3 New API endpoints (all JWT-protected unless noted)
|
||||
|
||||
```text
|
||||
# Sources (Phase A)
|
||||
POST /api/ai/course-plan/<id>/sources create URL or text source
|
||||
POST /api/ai/course-plan/<id>/sources/upload multipart file upload
|
||||
GET /api/ai/course-plan/<id>/sources list sources for plan
|
||||
POST /api/ai/course-plan/sources/<sid>/reindex re-extract + re-embed
|
||||
DELETE /api/ai/course-plan/sources/<sid> cascade-deletes embeddings
|
||||
|
||||
# Deliverables (Phase B)
|
||||
GET /api/ai/course-plan/<id>/deliverables {summary, weeks: [...]}
|
||||
|
||||
# Media (Phase C)
|
||||
GET /api/ai/course-plan/material/<mid>/media list media for material
|
||||
POST /api/ai/course-plan/material/<mid>/media/audio generate audio
|
||||
POST /api/ai/course-plan/material/<mid>/media/image generate image
|
||||
POST /api/ai/course-plan/material/<mid>/media/video compose video
|
||||
DELETE /api/ai/course-plan/media/<media_id> delete media + attachment
|
||||
POST /api/ai/course-plan/<id>/week/<n>/media bulk-generate week (audio+image)
|
||||
|
||||
# Assignments (Phase D)
|
||||
GET /api/ai/course-plan/<id>/assignments
|
||||
POST /api/ai/course-plan/<id>/assignments
|
||||
DELETE /api/ai/course-plan/assignments/<aid>
|
||||
|
||||
# Student-side (Phase E)
|
||||
GET /api/student/course-plans list assigned plans
|
||||
GET /api/student/course-plans/<id> read-only drilldown w/ media URLs
|
||||
```
|
||||
|
||||
### 24.4 New / updated frontend surfaces
|
||||
|
||||
* **Wizard** (`pages/admin/wizards/CoursePlanWizard.tsx`): now 6 steps — Brief → Resources (source builder) → Deliverables preview → Multimedia toggles → Review → Generate. Sources are queued client-side and uploaded post-creation against the new plan id.
|
||||
* **Detail page** (`pages/admin/AdminCoursePlanDetail.tsx`): now shows `DeliverablesStrip`, `SourcesCard`, `AssignmentsCard` (with `AssignDialog`), and per-material `MediaDrawer` (audio/image/video preview, generate, download, delete). Plus a "Generate week media" bulk action per week.
|
||||
* **Student** (`pages/student/StudentCoursePlans.tsx`, `StudentCoursePlanDetail.tsx`): list + read-only drilldown, `<audio>` / `<img>` / `<video>` tiles fed from `/web/content/<attachment_id>`.
|
||||
* **Sidebar**: new "My Course Plans" entry under student nav (`StudentLayout.tsx`).
|
||||
* **i18n**: ~140 new keys in EN + AR across `coursePlan.wizard.*`, `coursePlan.sources.*`, `coursePlan.deliverables.*`, `coursePlan.media.*`, `coursePlan.assignments.*`, `coursePlan.student.*`, `nav.myCoursePlans`.
|
||||
|
||||
### 24.5 Smoke test (run via `odoo-bin shell`)
|
||||
|
||||
`smoke_course_plan.py` exercises every phase end-to-end:
|
||||
|
||||
| Phase | Result |
|
||||
|---|---|
|
||||
| A — index inline-text source | PASS · 1 chunk · 295 chars (uses local MiniLM embeddings) |
|
||||
| B — `compute_deliverables` | PASS · 12 weeks · 5 ready / 1 generated / 0 planned |
|
||||
| C — DALL-E 3 image | PASS · 753 KB PNG generated for a `reading_text` material |
|
||||
| C — Polly audio | Gracefully marked `failed` with `"AWS credentials not configured — set in AI Settings"` (expected on dev box) |
|
||||
| D — assignment | PASS · created `mode='students'` assignment for Sarah |
|
||||
| E — visibility | PASS · `expand_user_ids()` returns Sarah · plan in student listing |
|
||||
|
||||
Run it any time:
|
||||
|
||||
```bash
|
||||
.conda-envs/odoo19/bin/python odoo/odoo-bin shell \
|
||||
-c odoo.conf -d encoach_v2 --no-http \
|
||||
< smoke_course_plan.py
|
||||
```
|
||||
|
||||
### 24.6 Operational notes
|
||||
|
||||
* **External providers are optional**. AWS Polly / ElevenLabs / OpenAI image keys live in **AI Settings** (`encoach.ai.settings`); when missing the corresponding media row is created with `status='failed'` and a clear error — the rest of the flow (planning, RAG, deliverables, assignment) still works.
|
||||
* **`ffmpeg`** must be on `PATH` for video composition. If it's missing, the video media row is `failed` with a one-line install hint; nothing else breaks.
|
||||
* **Image budget** per plan is the system parameter `encoach_ai_course.image_budget_per_plan` (default 60). Bump it via Settings → Technical → System Parameters.
|
||||
* **Plan-scoped retrieval**. The `resources.search` tool now accepts a `plan_id`; the LLM is auto-given the plan id in the system prompt of `course_planner` and `course_week_materials`, so RAG only ever sees that plan's own sources.
|
||||
* **Cascade behaviour**. Deleting a source unlinks its `encoach.embedding` rows; deleting a plan unlinks sources, media, assignments, and the underlying `ir.attachment`s.
|
||||
|
||||
### 24.7 Files added / changed in this pass
|
||||
|
||||
```text
|
||||
backend/custom_addons/encoach_ai_course/models/course_plan_source.py + new
|
||||
backend/custom_addons/encoach_ai_course/models/course_plan_media.py + new
|
||||
backend/custom_addons/encoach_ai_course/models/course_plan_assignment.py + new
|
||||
backend/custom_addons/encoach_ai_course/services/source_indexer.py + new
|
||||
backend/custom_addons/encoach_ai_course/services/deliverables.py + new
|
||||
backend/custom_addons/encoach_ai_course/services/media_service.py + new
|
||||
backend/custom_addons/encoach_ai_course/controllers/course_plan.py ~ +480 LOC
|
||||
backend/custom_addons/encoach_ai_course/security/ir.model.access.csv ~ access rights
|
||||
backend/custom_addons/encoach_ai/models/ai_agent.py ~ TOOL_CATEGORIES += media
|
||||
backend/custom_addons/encoach_ai/data/agents_defaults.xml ~ + 3 tools, + 1 agent
|
||||
backend/custom_addons/encoach_vector/models/embedding.py ~ plan_id filter
|
||||
frontend/src/types/coursePlan.ts ~ +4 interfaces
|
||||
frontend/src/services/coursePlan.service.ts ~ +18 methods
|
||||
frontend/src/pages/admin/wizards/CoursePlanWizard.tsx ~ +SourcesStep, MediaStep
|
||||
frontend/src/pages/admin/AdminCoursePlanDetail.tsx ~ rewrite (Deliverables, Sources, Assignments, MediaDrawer)
|
||||
frontend/src/pages/student/StudentCoursePlans.tsx + new
|
||||
frontend/src/pages/student/StudentCoursePlanDetail.tsx + new
|
||||
frontend/src/App.tsx ~ student routes
|
||||
frontend/src/components/StudentLayout.tsx ~ My Course Plans link
|
||||
frontend/src/i18n/locales/en.ts ~ +140 keys
|
||||
frontend/src/i18n/locales/ar.ts ~ +140 keys
|
||||
smoke_course_plan.py + new (E2E smoke test)
|
||||
```
|
||||
|
||||
### 24.8 Gotchas resolved during this pass
|
||||
|
||||
* `encoach.ai.tool.category` is a strict `Selection`; adding the three media tools required adding `('media', 'Media generation')` to `TOOL_CATEGORIES` in `ai_agent.py`. Without that the data file refused to load and the whole `encoach_ai` module rolled back its upgrade — silently leaving the new course-plan tables uncreated until we restarted with `-u encoach_vector,encoach_ai,encoach_ai_course`.
|
||||
* Odoo XML data loaders refuse two `<record id="..."/>` with the same id in the same file (no implicit merge). The `course_week_materials` agent had to be edited *in place* with the new media tools instead of being patched as a second record at the bottom.
|
||||
* `pypdf` extracts most encrypted-but-public PDFs; if both `pypdf` and `PyPDF2` are missing the indexer marks the source `failed` with a clear pip hint rather than crashing.
|
||||
* The student-side endpoint uses `expand_user_ids()` so a `mode='batch'` assignment auto-includes any future student rolled into that batch — no need to re-assign per intake.
|
||||
|
||||
57
frontend/.gitea/workflows/deploy.yml
Normal file
57
frontend/.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Deploy Frontend to Staging
|
||||
|
||||
# Triggered on every push to main.
|
||||
# Syncs this repo's source into the backend monorepo's frontend/
|
||||
# directory and rebuilds the encoach-frontend Docker container.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-frontend
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy frontend to staging
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Sync frontend source to backend repo
|
||||
run: |
|
||||
FRONTEND_SRC="/opt/encoach/encoach_frontend_new_v2"
|
||||
FRONTEND_DEST="/opt/encoach/encoach_backend_new_v2/frontend"
|
||||
|
||||
# Keep a clean clone of this repo on the server
|
||||
if [ -d "$FRONTEND_SRC/.git" ]; then
|
||||
git -C "$FRONTEND_SRC" fetch origin
|
||||
git -C "$FRONTEND_SRC" reset --hard origin/main
|
||||
else
|
||||
git clone http://localhost:3003/devops/encoach_frontend_new_v2.git "$FRONTEND_SRC"
|
||||
fi
|
||||
|
||||
echo "Syncing to backend frontend/ dir..."
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'dist' \
|
||||
--exclude 'docs' \
|
||||
"$FRONTEND_SRC/" "$FRONTEND_DEST/"
|
||||
echo "Latest commit: $(git -C $FRONTEND_SRC log -1 --oneline)"
|
||||
|
||||
- name: Rebuild frontend container
|
||||
run: |
|
||||
cd /opt/encoach/encoach_backend_new_v2
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f /opt/encoach/overrides/encoach.override.yml \
|
||||
up -d --build frontend
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}" | grep encoach-frontend
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
|
||||
echo "frontend / => HTTP $STATUS"
|
||||
test "$STATUS" = "200" || exit 1
|
||||
echo "Deploy successful!"
|
||||
@@ -133,6 +133,8 @@ const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscuss
|
||||
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
||||
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
||||
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
||||
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
|
||||
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
|
||||
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
||||
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
||||
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
||||
@@ -269,6 +271,8 @@ const App = () => (
|
||||
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
||||
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
||||
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
||||
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
|
||||
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
|
||||
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
||||
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
||||
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
@@ -238,9 +239,16 @@ function RouteContentFallback() {
|
||||
|
||||
// ============= Main Layout =============
|
||||
export default function AdminLmsLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
|
||||
// on the wizard's own Next/Finish footer (real bug repro: trying to
|
||||
// click "Next" in the Course-plan wizard at the standard 1024×768
|
||||
// viewport hits the pill instead). The AI Assistant orb also at the
|
||||
// bottom-right is preserved — it's smaller and useful in-wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -261,6 +269,44 @@ export default function AdminLmsLayout() {
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("nav.entities")}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{user.entities.map((entity) => (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => {
|
||||
setSelectedEntityId(entity.id);
|
||||
// Force page data to refresh against the newly
|
||||
// selected entity scope.
|
||||
window.location.reload();
|
||||
}}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{selectedEntity?.id === entity.id && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("common.active", "Active")}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationDropdown />
|
||||
@@ -303,13 +349,15 @@ export default function AdminLmsLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 end-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
||||
import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
@@ -7,14 +7,16 @@ import {
|
||||
BreadcrumbPage, BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
|
||||
import React from "react";
|
||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -90,6 +92,14 @@ function RouteContentFallback() {
|
||||
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
|
||||
// intercepting clicks on the wizard's own Next/Finish footer at most
|
||||
// viewport heights, blocking users from finishing the flow. The AI
|
||||
// Assistant orb (also bottom-right) stays — it's smaller and
|
||||
// genuinely useful inside the wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -103,6 +113,34 @@ export default function AppLayout() {
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{user.entities.map((entity) => {
|
||||
const active = selectedEntity?.id === entity.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => setSelectedEntityId(entity.id)}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{active ? <Badge variant="secondary">Current</Badge> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -145,14 +183,15 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
{/* Floating help button */}
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -17,6 +17,7 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
|
||||
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||
],
|
||||
|
||||
214
frontend/src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
214
frontend/src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import type { Resource } from "@/types";
|
||||
|
||||
/**
|
||||
* Generic, reusable picker over `/api/resources`.
|
||||
*
|
||||
* Two callers wire this up today:
|
||||
*
|
||||
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
|
||||
* the picked resources to `coursePlanService.attachResources` in the
|
||||
* `onConfirm` handler and invalidates the sources query.
|
||||
*
|
||||
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
|
||||
* so the wizard simply pushes the picks into its draft state and
|
||||
* attaches them in one batch after the plan is created.
|
||||
*
|
||||
* The dialog itself is purposefully agnostic: it only reports the
|
||||
* selected `Resource` rows; the parent decides what to do.
|
||||
*/
|
||||
export function LibraryPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
alreadyLinkedIds,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Resources whose ids are in this set render disabled + checked. */
|
||||
alreadyLinkedIds?: Set<number>;
|
||||
/** Called when the admin clicks "Attach selected". */
|
||||
onConfirm: (resources: Resource[]) => void | Promise<void>;
|
||||
/** Optional spinner state from the parent's mutation. */
|
||||
isPending?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
// Reset every time the dialog re-opens so a previous session's
|
||||
// selection doesn't bleed into the next attach.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(new Set());
|
||||
setSearch("");
|
||||
setTypeFilter("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["library-resources", { search, type: typeFilter }],
|
||||
queryFn: () =>
|
||||
resourcesService.list({
|
||||
search: search || undefined,
|
||||
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||||
review_status: "approved",
|
||||
}),
|
||||
enabled: open,
|
||||
});
|
||||
const resources: Resource[] = data?.items ?? [];
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const picked = resources.filter((r) => selected.has(r.id));
|
||||
if (!picked.length) return;
|
||||
await onConfirm(picked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Library className="h-5 w-5 text-primary" />
|
||||
{t("coursePlan.sources.libraryTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("coursePlan.sources.libraryDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Input
|
||||
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("coursePlan.sources.libraryTypeAll")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="document">DOCX</SelectItem>
|
||||
<SelectItem value="link">URL</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
|
||||
{isLoading && <Skeleton className="h-32 w-full" />}
|
||||
{!isLoading && resources.length === 0 && (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
{t("coursePlan.sources.libraryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading &&
|
||||
resources.map((r) => {
|
||||
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
|
||||
const isSelected = selected.has(r.id);
|
||||
return (
|
||||
<label
|
||||
key={r.id}
|
||||
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
|
||||
isLinked
|
||||
? "opacity-60 cursor-not-allowed bg-muted/40"
|
||||
: "cursor-pointer hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isLinked || isSelected}
|
||||
disabled={isLinked}
|
||||
onCheckedChange={() => !isLinked && toggle(r.id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium truncate">{r.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] capitalize"
|
||||
>
|
||||
{r.resource_type || r.type || "resource"}
|
||||
</Badge>
|
||||
{isLinked && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("coursePlan.sources.libraryAlreadyLinked")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" › ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-3">
|
||||
<span className="mr-auto text-xs text-muted-foreground self-center">
|
||||
{t("coursePlan.sources.librarySelectedCount", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selected.size || isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("coursePlan.sources.libraryAttach")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/coursePlan/MaterialBookView.tsx
Normal file
78
frontend/src/components/coursePlan/MaterialBookView.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CoursePlanMaterial } from "@/types";
|
||||
|
||||
export const SKILL_STYLE: Record<string, string> = {
|
||||
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
writing: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
listening: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
speaking: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
grammar: "bg-rose-100 text-rose-800 border-rose-200",
|
||||
vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200",
|
||||
integrated: "bg-slate-100 text-slate-800 border-slate-200",
|
||||
};
|
||||
|
||||
export function SkillBadge({ skill }: { skill: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("capitalize border", SKILL_STYLE[skill] ?? SKILL_STYLE.integrated)}
|
||||
>
|
||||
{skill || "integrated"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAny(value: unknown): React.ReactNode {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return <p className="leading-7">{String(value)}</p>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
return (
|
||||
<ul className="list-disc ps-5 space-y-1">
|
||||
{value.map((item, idx) => (
|
||||
<li key={idx}>{renderAny(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
|
||||
{k.replace(/_/g, " ")}
|
||||
</h5>
|
||||
<div className="text-sm">{renderAny(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MaterialBookView({
|
||||
material,
|
||||
}: {
|
||||
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||
}) {
|
||||
const body = material.body ?? {};
|
||||
const hasStructured = Object.keys(body).length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
||||
{hasStructured ? (
|
||||
renderAny(body)
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-7">
|
||||
{material.body_text || "No content available yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
import { authService } from "@/services/auth.service";
|
||||
import { clearToken } from "@/lib/api-client";
|
||||
import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client";
|
||||
import type { User, UserRole } from "@/types/auth";
|
||||
|
||||
export type { UserRole };
|
||||
@@ -9,6 +9,9 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
selectedEntityId: number | null;
|
||||
selectedEntity: User["entities"][number] | null;
|
||||
setSelectedEntityId: (entityId: number | null) => void;
|
||||
login: (email: string, password: string) => Promise<User>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const syncEntitySelection = useCallback((nextUser: User | null) => {
|
||||
const available = nextUser?.entities ?? [];
|
||||
if (available.length === 0) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
const stored = getActiveEntityId();
|
||||
const validStored = stored && available.some((e) => e.id === stored) ? stored : null;
|
||||
const next = validStored ?? available[0].id;
|
||||
setSelectedEntityIdState(next);
|
||||
setActiveEntityId(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("encoach_token");
|
||||
if (!token) {
|
||||
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
authService
|
||||
.getUser()
|
||||
.then(setUser)
|
||||
.then((u) => {
|
||||
setUser(u);
|
||||
syncEntitySelection(u);
|
||||
})
|
||||
.catch(() => {
|
||||
clearToken();
|
||||
setActiveEntityId(null);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string): Promise<User> => {
|
||||
const res = await authService.login({ login: email, password });
|
||||
setUser(res.user);
|
||||
syncEntitySelection(res.user);
|
||||
return res.user;
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const setSelectedEntityId = useCallback((entityId: number | null) => {
|
||||
if (!user) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (entityId == null) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (!user.entities.some((e) => e.id === entityId)) return;
|
||||
setSelectedEntityIdState(entityId);
|
||||
setActiveEntityId(entityId);
|
||||
}, [user]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
}, []);
|
||||
|
||||
const selectedEntity =
|
||||
user?.entities.find((e) => e.id === selectedEntityId) ??
|
||||
user?.entities[0] ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
selectedEntityId,
|
||||
selectedEntity,
|
||||
setSelectedEntityId,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
|
||||
@@ -60,6 +60,7 @@ const ar: Translations = {
|
||||
dashboard: "لوحة التحكم",
|
||||
courses: "الدورات",
|
||||
myCourses: "دوراتي",
|
||||
myCoursePlans: "خططي الدراسية",
|
||||
students: "الطلاب",
|
||||
teachers: "المعلمون",
|
||||
batches: "الدفعات",
|
||||
@@ -739,8 +740,14 @@ const ar: Translations = {
|
||||
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
||||
coverage: "التغطية",
|
||||
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
||||
sources: "المصادر المرجعية",
|
||||
sourcesDesc:
|
||||
"ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.",
|
||||
scope: "النطاق",
|
||||
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
||||
media: "الوسائط",
|
||||
mediaDesc:
|
||||
"اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.",
|
||||
review: "المراجعة",
|
||||
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
||||
},
|
||||
@@ -767,7 +774,178 @@ const ar: Translations = {
|
||||
cefrRequired: "يرجى اختيار مستوى CEFR.",
|
||||
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
|
||||
},
|
||||
review: {
|
||||
sourcesCount_one: "{{count}} مصدر",
|
||||
sourcesCount_other: "{{count}} مصادر",
|
||||
noSources: "لا توجد مصادر",
|
||||
mediaNone: "لا يوجد توليد وسائط",
|
||||
},
|
||||
},
|
||||
sections_extras: {
|
||||
sources: "المصادر المرجعية",
|
||||
progress: "تقدّم التوليد",
|
||||
assignments: "الإسنادات",
|
||||
media: "الوسائط المُولَّدة",
|
||||
},
|
||||
sources: {
|
||||
sectionTitle: "المصادر المرجعية (RAG)",
|
||||
sectionDesc:
|
||||
"ارفع PDF أو أضف روابط أو نصوصاً. يستخدمها الذكاء الاصطناعي كمرجع عند توليد المواد الأسبوعية.",
|
||||
collected_one: "{{count}} مصدر جاهز",
|
||||
collected_other: "{{count}} مصادر جاهزة",
|
||||
empty: "لا توجد مصادر مرجعية بعد.",
|
||||
dropTitle: "أضف مواد مرجعية",
|
||||
dropHint: "PDF أو DOCX أو TXT أو HTML أو نص — حتى بضعة ميغابايت لكل ملف.",
|
||||
uploadFiles: "رفع ملفات",
|
||||
urlLabel: "إضافة رابط",
|
||||
textLabel: "أو ألصق نصاً مباشرة",
|
||||
textPlaceholder: "ألصق فقرة لتوجيه الذكاء الاصطناعي…",
|
||||
uploadFailed: "تعذّر رفع \"{{name}}\".",
|
||||
reindex: "إعادة فهرسة",
|
||||
reindexed: "أُعيدت فهرسة المصدر.",
|
||||
reindexFailed: "فشلت إعادة الفهرسة.",
|
||||
deleted: "تم حذف المصدر.",
|
||||
deleteFailed: "تعذّر حذف المصدر.",
|
||||
status: {
|
||||
pending: "في الانتظار",
|
||||
indexing: "يُفهرس…",
|
||||
indexed: "مُفهرس",
|
||||
failed: "فشل",
|
||||
},
|
||||
kindLabel: {
|
||||
file: "ملف",
|
||||
url: "رابط",
|
||||
text: "نص",
|
||||
resource: "المكتبة",
|
||||
},
|
||||
chunks_one: "{{count}} جزء",
|
||||
chunks_other: "{{count}} أجزاء",
|
||||
pickFromLibrary: "اختيار من المكتبة",
|
||||
libraryHint: "أعد استخدام مورد قمت برفعه من قبل.",
|
||||
libraryTitle: "اختر من مكتبة الموارد",
|
||||
libraryDescription:
|
||||
"اختر مورداً واحداً أو أكثر من الموارد المعتمدة لدعم الذكاء الاصطناعي. سنقوم بفهرستها تماماً مثل الملفات المرفوعة.",
|
||||
librarySearchPlaceholder: "ابحث بالعنوان…",
|
||||
libraryTypeAll: "كل الأنواع",
|
||||
libraryEmpty: "لا توجد موارد مطابقة لهذه الفلاتر.",
|
||||
libraryAlreadyLinked: "مربوط مسبقاً",
|
||||
libraryAttach: "ربط المختار",
|
||||
librarySelectedCount_one: "تم اختيار {{count}}",
|
||||
librarySelectedCount_other: "تم اختيار {{count}}",
|
||||
libraryAttached_one: "تم ربط {{count}} مورد.",
|
||||
libraryAttached_other: "تم ربط {{count}} موارد.",
|
||||
librarySkipped_one: "{{count}} مورد كان مربوطاً مسبقاً.",
|
||||
librarySkipped_other: "{{count}} موارد كانت مربوطة مسبقاً.",
|
||||
libraryAttachFailed: "تعذّر ربط الموارد المختارة.",
|
||||
linkedToLibrary: "مرتبط بـ /admin/resources",
|
||||
fromLibrary: "المكتبة",
|
||||
libraryPickTitle: "اختر من المكتبة",
|
||||
libraryPickHint: "أعد استخدام موارد معتمدة سبق رفعها إلى /admin/resources.",
|
||||
libraryPickButton: "تصفح المكتبة",
|
||||
},
|
||||
sourceKind: {
|
||||
file: "ملف",
|
||||
url: "رابط",
|
||||
text: "نص",
|
||||
},
|
||||
deliverables: {
|
||||
title: "تقدّم التوليد",
|
||||
subtitle:
|
||||
"ما الذي سيُنتجه الذكاء الاصطناعي، وما تم إنتاجه، وما اكتمل تماماً (مواد + وسائط).",
|
||||
summary: {
|
||||
planned_one: "{{count}} مخطّط",
|
||||
planned_other: "{{count}} مخطّط",
|
||||
generated_one: "{{count}} مُولَّد",
|
||||
generated_other: "{{count}} مُولَّد",
|
||||
ready_one: "{{count}} جاهز",
|
||||
ready_other: "{{count}} جاهز",
|
||||
},
|
||||
mediaCounts: "صوت {{audio}} · صورة {{image}} · فيديو {{video}}",
|
||||
status: {
|
||||
planned: "مخطّط",
|
||||
generated: "مُولَّد",
|
||||
ready: "جاهز",
|
||||
},
|
||||
perWeek: "تفصيل لكل أسبوع",
|
||||
noWeeks: "لا توجد أسابيع — ولّد الخطة أولاً.",
|
||||
},
|
||||
media: {
|
||||
audio: "صوت",
|
||||
image: "صورة",
|
||||
video: "فيديو",
|
||||
audioTitle: "تعليق صوتي",
|
||||
audioDesc:
|
||||
"توليد صوت منطوق (Polly / ElevenLabs) لنصوص الاستماع ومحفّزات المحادثة.",
|
||||
imageTitle: "صور تغطية",
|
||||
imageDesc:
|
||||
"توليد صور DALL·E 3 لنصوص القراءة والاستماع. يحتسب من ميزانية الصور للخطة.",
|
||||
videoTitle: "فيديو شرائح",
|
||||
videoDesc: "دمج الصوت + الصورة في فيديو MP4 قصير عبر ffmpeg. أبطأ الخيارات.",
|
||||
hint:
|
||||
"الصوت مفعّل افتراضياً. الفيديو معطّل افتراضياً — فعّله بعد مراجعة الصوت والصورة.",
|
||||
drawerTitle: "وسائط \"{{title}}\"",
|
||||
drawerSubtitle: "ولّد أو احذف الصوت والصورة والفيديو.",
|
||||
generateAudio: "توليد صوت",
|
||||
generateImage: "توليد صورة",
|
||||
generateVideo: "توليد فيديو",
|
||||
generating: "جاري التوليد…",
|
||||
audioReady: "الصوت جاهز",
|
||||
imageReady: "الصورة جاهزة",
|
||||
videoReady: "الفيديو جاهز",
|
||||
audioFailed: "فشل توليد الصوت.",
|
||||
imageFailed: "فشل توليد الصورة.",
|
||||
videoFailed: "فشل توليد الفيديو.",
|
||||
deleted: "تم حذف الوسيط.",
|
||||
deleteFailed: "تعذّر حذف الوسيط.",
|
||||
open: "فتح",
|
||||
download: "تنزيل",
|
||||
noMedia: "لا توجد وسائط لهذه المادة بعد.",
|
||||
mediaForMaterial: "وسائط",
|
||||
bulk: "توليد وسائط الأسبوع",
|
||||
bulkSuccess: "اكتمل توليد وسائط الأسبوع.",
|
||||
bulkFailed: "تعذّر توليد وسائط الأسبوع.",
|
||||
provider: "المزوّد",
|
||||
},
|
||||
assignments: {
|
||||
title: "مُسندة إلى",
|
||||
empty: "لم تُسند إلى أحد بعد.",
|
||||
assign: "إسناد",
|
||||
assignTitle: "إسناد خطة المقرر",
|
||||
assignSubtitle:
|
||||
"اختر شعبة (دفعة) للجميع، أو طلاباً محدّدين. ستظهر الخطة في لوحة الطالب.",
|
||||
modeBatch: "دفعة كاملة",
|
||||
modeStudents: "طلاب محدّدون",
|
||||
pickBatch: "اختر دفعة",
|
||||
pickStudents: "اختر طلاباً",
|
||||
noBatches: "لا توجد دفعات.",
|
||||
noStudents: "لا يوجد طلاب.",
|
||||
dueDate: "تاريخ التسليم (اختياري)",
|
||||
message: "رسالة للمتعلّمين (اختياري)",
|
||||
messagePlaceholder: "ترحيب، توقّعات، مواعيد…",
|
||||
created: "تم إسناد الخطة.",
|
||||
createFailed: "تعذّر إسناد الخطة.",
|
||||
removed: "تمت إزالة الإسناد.",
|
||||
removeFailed: "تعذّر إزالة الإسناد.",
|
||||
remove: "إزالة",
|
||||
confirmRemove: "إزالة هذا الإسناد؟",
|
||||
assignedBy: "أسندها {{name}}",
|
||||
students_one: "{{count}} طالب",
|
||||
students_other: "{{count}} طلاب",
|
||||
cancel: "إلغاء",
|
||||
save: "إسناد",
|
||||
},
|
||||
student: {
|
||||
listTitle: "خططي الدراسية",
|
||||
listSubtitle:
|
||||
"خطط مناهج مخصّصة يُنشئها الذكاء الاصطناعي ويُسندها معلّمك. افتح خطة لرؤية الأسابيع والمواد والوسائط.",
|
||||
empty: "لم تُسند إليك خطط بعد.",
|
||||
open: "فتح المقرر",
|
||||
assignedBy: "أسندها {{name}}",
|
||||
due: "موعد التسليم {{date}}",
|
||||
noDue: "بدون موعد",
|
||||
backToList: "العودة إلى خططي",
|
||||
},
|
||||
wizardSourcesStep: "المصادر",
|
||||
},
|
||||
aiAdmin: {
|
||||
title: "وكلاء الذكاء الاصطناعي والأدوات",
|
||||
@@ -777,6 +955,83 @@ const ar: Translations = {
|
||||
agents: "الوكلاء",
|
||||
tools: "الأدوات",
|
||||
prompts: "التعليمات",
|
||||
providers: "المزوّدون والمفاتيح",
|
||||
},
|
||||
},
|
||||
aiProviders: {
|
||||
title: "مزوّدو الذكاء الاصطناعي ومفاتيح API",
|
||||
subtitle:
|
||||
"اختر المزوّد الفعّال لكل قدرة واحفظ مفاتيح API. تُطبَّق التغييرات في الطلب التالي مباشرةً دون الحاجة لإعادة تشغيل Odoo.",
|
||||
activeProvider: "المزوّد الفعّال",
|
||||
save: "حفظ الإعدادات",
|
||||
testButton: "اختبار سلسلة الاحتياط",
|
||||
noPaidKeys:
|
||||
"لا توجد مفاتيح API مدفوعة لهذه القدرة — سيتم استخدام البديل المجاني.",
|
||||
paidConfigured: "المزوّدون المدفوعون المهيَّؤون:",
|
||||
empty: "تعذّر تحميل الإعدادات.",
|
||||
footer:
|
||||
"تُقرأ إعدادات المزوّدين من ir.config_parameter في كل طلب — بدون تخزين مؤقّت ولا حاجة لإعادة تشغيل.",
|
||||
cap: {
|
||||
text: "توليد النصوص",
|
||||
text_hint: "يستخدمه كل وكلاء LangGraph (مخطّط المقرر، مولّد الاختبارات، المصحّحون).",
|
||||
image: "توليد الصور",
|
||||
image_hint: "رسومات لنصوص القراءة ومشاهد الاستماع وبطاقات المفردات.",
|
||||
audio: "الصوت (TTS)",
|
||||
audio_hint: "سرد نصوص الاستماع وأمثلة إجابات أسئلة المحادثة.",
|
||||
video: "تركيب الفيديو",
|
||||
video_hint: "مقاطع MP4 تجمع بين الصورة المُولَّدة والسرد الصوتي.",
|
||||
},
|
||||
kind: {
|
||||
paid: "مدفوع",
|
||||
free: "مجاني",
|
||||
auto: "تلقائي",
|
||||
},
|
||||
keys: {
|
||||
title: "مفاتيح API",
|
||||
subtitle:
|
||||
"المفاتيح للكتابة فقط وتُحفَظ مشفّرة في ir.config_parameter. لا تُرجَع أبداً إلى المتصفّح.",
|
||||
saved: "محفوظ",
|
||||
unsaved: "غير محفوظ",
|
||||
clear: "مسح",
|
||||
placeholderSaved: "•••••• (انقر للاستبدال)",
|
||||
openai: "مفتاح OpenAI",
|
||||
openai_hint: "يُستخدَم لـ GPT-4o والتضمين و DALL-E 3.",
|
||||
aws_access: "AWS Access Key ID",
|
||||
aws_secret: "AWS Secret Access Key",
|
||||
elevenlabs: "مفتاح ElevenLabs",
|
||||
gptzero: "مفتاح GPTZero",
|
||||
paymob_api: "مفتاح Paymob API",
|
||||
paymob_integration: "Paymob integration ID",
|
||||
paymob_iframe: "Paymob iframe ID",
|
||||
paymob_hmac: "Paymob HMAC secret",
|
||||
},
|
||||
group: {
|
||||
openai: "OpenAI",
|
||||
aws: "AWS Polly",
|
||||
elevenlabs: "ElevenLabs",
|
||||
other: "خدمات ذكاء اصطناعي أخرى",
|
||||
paymob: "Paymob (المدفوعات)",
|
||||
},
|
||||
plain: {
|
||||
title: "النموذج والتشغيل",
|
||||
subtitle:
|
||||
"معلَمات تشغيل غير سرّية: النموذج الافتراضي، منطقة AWS، مهلة الطلب.",
|
||||
openai_model: "نموذج OpenAI",
|
||||
openai_fast: "نموذج OpenAI السريع",
|
||||
aws_region: "منطقة AWS",
|
||||
elevenlabs_model: "نموذج ElevenLabs",
|
||||
timeout: "مهلة الطلب (ثوانٍ)",
|
||||
max_retries: "أقصى عدد محاولات",
|
||||
},
|
||||
toast: {
|
||||
loadFailed: "تعذّر تحميل إعدادات الذكاء الاصطناعي",
|
||||
saved: "تم حفظ إعدادات المزوّد",
|
||||
savedDescription:
|
||||
"تم تحديث المزوّدين. تُطبَّق التغييرات في الطلب التالي.",
|
||||
saveFailed: "تعذّر حفظ الإعدادات",
|
||||
testOk: "سلسلة المزوّد جاهزة",
|
||||
testPartial: "بعض المزوّدين تنقصه بيانات الاعتماد",
|
||||
testFailed: "فشل اختبار المزوّد",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface Translations {
|
||||
aiAdmin: Record<string, unknown>;
|
||||
agents: Record<string, unknown>;
|
||||
tools: Record<string, unknown>;
|
||||
/** AI provider selection & API key management (Providers & Keys tab). */
|
||||
aiProviders: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const en: Translations = {
|
||||
@@ -105,6 +107,7 @@ const en: Translations = {
|
||||
dashboard: "Dashboard",
|
||||
courses: "Courses",
|
||||
myCourses: "My Courses",
|
||||
myCoursePlans: "My Course Plans",
|
||||
students: "Students",
|
||||
teachers: "Teachers",
|
||||
batches: "Batches",
|
||||
@@ -796,8 +799,14 @@ const en: Translations = {
|
||||
basicsDesc: "Name the course and set level, duration, and weekly hours.",
|
||||
coverage: "Coverage",
|
||||
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
|
||||
sources: "Reference sources",
|
||||
sourcesDesc:
|
||||
"Drop PDFs, URLs, or pasted text. The AI uses them as grounding when generating each week's materials.",
|
||||
scope: "Scope",
|
||||
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
|
||||
media: "Multimedia",
|
||||
mediaDesc:
|
||||
"Pick which media the AI should auto-produce alongside the text materials.",
|
||||
review: "Review",
|
||||
reviewDesc: "Double-check your brief before kicking off the generation.",
|
||||
},
|
||||
@@ -825,7 +834,180 @@ const en: Translations = {
|
||||
cefrRequired: "Please pick a CEFR level.",
|
||||
weeksRange: "Total weeks must be at least 1.",
|
||||
},
|
||||
review: {
|
||||
sourcesCount_one: "{{count}} reference source",
|
||||
sourcesCount_other: "{{count}} reference sources",
|
||||
noSources: "No reference sources",
|
||||
mediaNone: "No media generation",
|
||||
},
|
||||
},
|
||||
sections_extras: {
|
||||
sources: "Reference sources",
|
||||
progress: "Generation progress",
|
||||
assignments: "Assignments",
|
||||
media: "Generated media",
|
||||
},
|
||||
sources: {
|
||||
sectionTitle: "Reference sources (RAG)",
|
||||
sectionDesc:
|
||||
"Upload PDFs, paste URLs, or drop in raw text. The AI will use the indexed content as grounding when it writes weekly materials.",
|
||||
collected_one: "{{count}} source ready",
|
||||
collected_other: "{{count}} sources ready",
|
||||
empty: "No reference sources yet.",
|
||||
dropTitle: "Add reference materials",
|
||||
dropHint: "PDF, DOCX, TXT, HTML, or plain text — up to a few MB each.",
|
||||
uploadFiles: "Upload files",
|
||||
urlLabel: "Add a URL",
|
||||
textLabel: "Or paste text directly",
|
||||
textPlaceholder: "Paste a passage to ground the AI on…",
|
||||
uploadFailed: "Couldn't upload \"{{name}}\".",
|
||||
reindex: "Reindex",
|
||||
reindexed: "Source reindexed.",
|
||||
reindexFailed: "Reindex failed.",
|
||||
deleted: "Source deleted.",
|
||||
deleteFailed: "Couldn't delete source.",
|
||||
status: {
|
||||
pending: "Pending",
|
||||
indexing: "Indexing…",
|
||||
indexed: "Indexed",
|
||||
failed: "Failed",
|
||||
},
|
||||
kindLabel: {
|
||||
file: "File",
|
||||
url: "URL",
|
||||
text: "Text",
|
||||
resource: "Library",
|
||||
},
|
||||
chunks_one: "{{count}} chunk",
|
||||
chunks_other: "{{count}} chunks",
|
||||
// Library picker — reuses /admin/resources items as RAG sources.
|
||||
pickFromLibrary: "Pick from library",
|
||||
libraryHint: "Reuse a resource you've already uploaded.",
|
||||
libraryTitle: "Pick from resource library",
|
||||
libraryDescription:
|
||||
"Select one or more approved resources to ground the AI on. We'll index them just like uploaded files.",
|
||||
librarySearchPlaceholder: "Search by title…",
|
||||
libraryTypeAll: "All types",
|
||||
libraryEmpty: "No resources match those filters.",
|
||||
libraryAlreadyLinked: "Already linked",
|
||||
libraryAttach: "Attach selected",
|
||||
librarySelectedCount_one: "{{count}} selected",
|
||||
librarySelectedCount_other: "{{count}} selected",
|
||||
libraryAttached_one: "Attached {{count}} resource.",
|
||||
libraryAttached_other: "Attached {{count}} resources.",
|
||||
librarySkipped_one: "{{count}} resource was already linked.",
|
||||
librarySkipped_other: "{{count}} resources were already linked.",
|
||||
libraryAttachFailed: "Couldn't attach the selected resources.",
|
||||
linkedToLibrary: "Linked to /admin/resources",
|
||||
fromLibrary: "Library",
|
||||
libraryPickTitle: "Pick from library",
|
||||
libraryPickHint: "Reuse approved resources you've already uploaded to /admin/resources.",
|
||||
libraryPickButton: "Browse library",
|
||||
},
|
||||
sourceKind: {
|
||||
file: "File",
|
||||
url: "URL",
|
||||
text: "Inline text",
|
||||
},
|
||||
deliverables: {
|
||||
title: "Generation progress",
|
||||
subtitle:
|
||||
"What the AI is going to produce, what it has produced, and what is fully ready (materials + media).",
|
||||
summary: {
|
||||
planned_one: "{{count}} planned",
|
||||
planned_other: "{{count}} planned",
|
||||
generated_one: "{{count}} generated",
|
||||
generated_other: "{{count}} generated",
|
||||
ready_one: "{{count}} ready",
|
||||
ready_other: "{{count}} ready",
|
||||
},
|
||||
mediaCounts: "Audio {{audio}} · Image {{image}} · Video {{video}}",
|
||||
status: {
|
||||
planned: "Planned",
|
||||
generated: "Generated",
|
||||
ready: "Ready",
|
||||
},
|
||||
perWeek: "Per-week breakdown",
|
||||
noWeeks: "No weeks yet — generate the plan first.",
|
||||
},
|
||||
media: {
|
||||
audio: "Audio",
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audioTitle: "Narration audio",
|
||||
audioDesc:
|
||||
"Generate spoken audio (Polly / ElevenLabs) for listening scripts and speaking prompts.",
|
||||
imageTitle: "Cover images",
|
||||
imageDesc:
|
||||
"Generate DALL·E 3 images for reading texts and listening scripts. Counts against the per-plan image budget.",
|
||||
videoTitle: "Slideshow video",
|
||||
videoDesc:
|
||||
"Combine audio + image into a short MP4 with ffmpeg. Slowest option.",
|
||||
hint:
|
||||
"Audio is on by default. Video is off by default — turn it on once you've reviewed the audio + image.",
|
||||
drawerTitle: "Media for \"{{title}}\"",
|
||||
drawerSubtitle: "Generate or remove audio, images, and slideshow video.",
|
||||
generateAudio: "Generate audio",
|
||||
generateImage: "Generate image",
|
||||
generateVideo: "Generate video",
|
||||
generating: "Generating…",
|
||||
audioReady: "Audio ready",
|
||||
imageReady: "Image ready",
|
||||
videoReady: "Video ready",
|
||||
audioFailed: "Audio generation failed.",
|
||||
imageFailed: "Image generation failed.",
|
||||
videoFailed: "Video generation failed.",
|
||||
deleted: "Media deleted.",
|
||||
deleteFailed: "Couldn't delete media.",
|
||||
open: "Open",
|
||||
download: "Download",
|
||||
noMedia: "No media yet for this material.",
|
||||
mediaForMaterial: "Media",
|
||||
bulk: "Generate week media",
|
||||
bulkSuccess: "Week media generation done.",
|
||||
bulkFailed: "Couldn't generate week media.",
|
||||
provider: "Provider",
|
||||
},
|
||||
assignments: {
|
||||
title: "Assigned to",
|
||||
empty: "Not assigned to anyone yet.",
|
||||
assign: "Assign",
|
||||
assignTitle: "Assign this course plan",
|
||||
assignSubtitle:
|
||||
"Pick a class (batch) for everyone, or individual students. They'll see the plan in their student dashboard.",
|
||||
modeBatch: "Whole class (batch)",
|
||||
modeStudents: "Individual students",
|
||||
pickBatch: "Select a batch",
|
||||
pickStudents: "Pick students",
|
||||
noBatches: "No batches available.",
|
||||
noStudents: "No students available.",
|
||||
dueDate: "Due date (optional)",
|
||||
message: "Message to learners (optional)",
|
||||
messagePlaceholder: "Welcome note, expectations, deadlines…",
|
||||
created: "Plan assigned.",
|
||||
createFailed: "Couldn't assign plan.",
|
||||
removed: "Assignment removed.",
|
||||
removeFailed: "Couldn't remove assignment.",
|
||||
remove: "Remove",
|
||||
confirmRemove: "Remove this assignment?",
|
||||
assignedBy: "Assigned by {{name}}",
|
||||
students_one: "{{count}} student",
|
||||
students_other: "{{count}} students",
|
||||
cancel: "Cancel",
|
||||
save: "Assign",
|
||||
},
|
||||
student: {
|
||||
listTitle: "My course plans",
|
||||
listSubtitle:
|
||||
"Personalised AI-generated curricula assigned by your teacher. Open one to see the weekly plan, materials, and media.",
|
||||
empty: "No plans assigned to you yet.",
|
||||
open: "Open course",
|
||||
assignedBy: "Assigned by {{name}}",
|
||||
due: "Due {{date}}",
|
||||
noDue: "No deadline",
|
||||
backToList: "Back to my plans",
|
||||
},
|
||||
wizardSourcesStep: "Sources",
|
||||
},
|
||||
aiAdmin: {
|
||||
title: "AI Agents & Tools",
|
||||
@@ -835,6 +1017,83 @@ const en: Translations = {
|
||||
agents: "Agents",
|
||||
tools: "Tools",
|
||||
prompts: "Prompts",
|
||||
providers: "Providers & Keys",
|
||||
},
|
||||
},
|
||||
aiProviders: {
|
||||
title: "AI Providers & API Keys",
|
||||
subtitle:
|
||||
"Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.",
|
||||
activeProvider: "Active provider",
|
||||
save: "Save settings",
|
||||
testButton: "Test fallback chain",
|
||||
noPaidKeys:
|
||||
"No paid API keys configured for this capability — free fallback will be used.",
|
||||
paidConfigured: "Configured paid providers:",
|
||||
empty: "Settings could not be loaded.",
|
||||
footer:
|
||||
"Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.",
|
||||
cap: {
|
||||
text: "Text generation",
|
||||
text_hint: "Used by every LangGraph agent (course planner, exam generator, graders).",
|
||||
image: "Image generation",
|
||||
image_hint: "Illustrations for reading texts, listening scenes, and vocabulary cards.",
|
||||
audio: "Audio (TTS)",
|
||||
audio_hint: "Listening-script narration and speaking-prompt model answers.",
|
||||
video: "Video composition",
|
||||
video_hint: "Slideshow MP4s combining a generated image with the audio narration.",
|
||||
},
|
||||
kind: {
|
||||
paid: "Paid",
|
||||
free: "Free",
|
||||
auto: "Auto",
|
||||
},
|
||||
keys: {
|
||||
title: "API keys",
|
||||
subtitle:
|
||||
"Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.",
|
||||
saved: "Saved",
|
||||
unsaved: "Unsaved",
|
||||
clear: "Clear",
|
||||
placeholderSaved: "•••••• (click to replace)",
|
||||
openai: "OpenAI API key",
|
||||
openai_hint: "Used for GPT-4o, embeddings, and DALL-E 3.",
|
||||
aws_access: "AWS Access Key ID",
|
||||
aws_secret: "AWS Secret Access Key",
|
||||
elevenlabs: "ElevenLabs API key",
|
||||
gptzero: "GPTZero API key",
|
||||
paymob_api: "Paymob API key",
|
||||
paymob_integration: "Paymob integration ID",
|
||||
paymob_iframe: "Paymob iframe ID",
|
||||
paymob_hmac: "Paymob HMAC secret",
|
||||
},
|
||||
group: {
|
||||
openai: "OpenAI",
|
||||
aws: "AWS Polly",
|
||||
elevenlabs: "ElevenLabs",
|
||||
other: "Other AI services",
|
||||
paymob: "Paymob (payments)",
|
||||
},
|
||||
plain: {
|
||||
title: "Model & runtime",
|
||||
subtitle:
|
||||
"Non-secret runtime parameters: default model, AWS region, request timeout.",
|
||||
openai_model: "OpenAI model",
|
||||
openai_fast: "OpenAI fast model",
|
||||
aws_region: "AWS region",
|
||||
elevenlabs_model: "ElevenLabs model",
|
||||
timeout: "Request timeout (seconds)",
|
||||
max_retries: "Max retries",
|
||||
},
|
||||
toast: {
|
||||
loadFailed: "Could not load AI settings",
|
||||
saved: "AI provider settings saved",
|
||||
savedDescription:
|
||||
"Active providers updated. Changes apply to the next request.",
|
||||
saveFailed: "Could not save settings",
|
||||
testOk: "Provider chain ready",
|
||||
testPartial: "Some providers missing credentials",
|
||||
testFailed: "Provider test failed",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
|
||||
@@ -101,11 +101,53 @@ export function describeApiError(
|
||||
const ACCESS_KEY = "encoach_token";
|
||||
const REFRESH_KEY = "encoach_refresh_token";
|
||||
const EXP_KEY = "encoach_token_exp";
|
||||
const ENTITY_KEY = "encoach_entity_id";
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function getActiveEntityId(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(ENTITY_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveEntityId(entityId: number | null | undefined): void {
|
||||
try {
|
||||
if (entityId && Number.isFinite(entityId)) {
|
||||
localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
|
||||
} else {
|
||||
localStorage.removeItem(ENTITY_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL to a media-streaming endpoint with the JWT attached as a
|
||||
* query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
|
||||
* (which can't attach custom Authorization headers) and by ``<a download>``
|
||||
* tags so the browser can fetch the binary directly without going through
|
||||
* a fetch + blob URL dance.
|
||||
*
|
||||
* Returns the original path unchanged when no token is stored, which lets
|
||||
* callers render a placeholder rather than crashing on ``null``.
|
||||
*/
|
||||
export function withAuthQuery(path: string): string {
|
||||
if (!path) return path;
|
||||
const token = getAccessToken();
|
||||
if (!token) return path;
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${path}${sep}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
}
|
||||
@@ -240,6 +282,11 @@ export type QueryParamValue =
|
||||
*/
|
||||
export type QueryParams = object;
|
||||
|
||||
const ENTITY_QUERY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||
const ENTITY_BODY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
|
||||
|
||||
function buildUrl(path: string, params?: QueryParams): string {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
@@ -253,9 +300,30 @@ function buildUrl(path: string, params?: QueryParams): string {
|
||||
url.searchParams.set(key, String(rawValue));
|
||||
}
|
||||
}
|
||||
// Multi-entity LMS scope: when the user has selected an active entity
|
||||
// in the UI, append it to entity-scoped endpoints unless the caller
|
||||
// already provided an explicit entity_id.
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (
|
||||
activeEntityId &&
|
||||
!url.searchParams.has("entity_id") &&
|
||||
ENTITY_QUERY_SCOPE_RE.test(path)
|
||||
) {
|
||||
url.searchParams.set("entity_id", String(activeEntityId));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (!activeEntityId) return body;
|
||||
if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||||
const rec = body as Record<string, unknown>;
|
||||
if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
|
||||
return { ...rec, entity_id: activeEntityId };
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
||||
@@ -315,26 +383,29 @@ export const api = {
|
||||
},
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PUT",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -7,10 +7,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react";
|
||||
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield, UserCog } from "lucide-react";
|
||||
import { entitiesService } from "@/services/entities.service";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: "corporate", label: "Corporate" },
|
||||
@@ -21,10 +23,16 @@ const ENTITY_TYPES = [
|
||||
];
|
||||
|
||||
export default function EntitiesPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdminUser = user?.user_type === "admin";
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const [manageEntity, setManageEntity] = useState<{ id: number; name: string } | null>(null);
|
||||
const [usersSearch, setUsersSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
|
||||
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
@@ -34,6 +42,18 @@ export default function EntitiesPage() {
|
||||
queryFn: () => entitiesService.list({ size: 200 }),
|
||||
});
|
||||
|
||||
const platformUsersQ = useQuery({
|
||||
queryKey: ["platform-users-for-entities"],
|
||||
queryFn: () => entitiesService.listPlatformUsers({ size: 500 }),
|
||||
enabled: isAdminUser && manageOpen,
|
||||
});
|
||||
|
||||
const entityUsersQ = useQuery({
|
||||
queryKey: ["entity-users", manageEntity?.id],
|
||||
queryFn: () => entitiesService.listEntityUsers(manageEntity!.id),
|
||||
enabled: isAdminUser && !!manageEntity?.id,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
||||
entitiesService.create(data as Partial<import("@/types").Entity>),
|
||||
@@ -66,6 +86,21 @@ export default function EntitiesPage() {
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const saveEntityUsersMut = useMutation({
|
||||
mutationFn: ({ entityId, userIds }: { entityId: number; userIds: number[] }) =>
|
||||
entitiesService.updateEntityUsers(entityId, userIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
qc.invalidateQueries({ queryKey: ["entity-users"] });
|
||||
setManageOpen(false);
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
toast({ title: "Entity users updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const raw = entitiesQ.data as unknown;
|
||||
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||
if (!raw) return [];
|
||||
@@ -82,11 +117,43 @@ export default function EntitiesPage() {
|
||||
);
|
||||
const loading = entitiesQ.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entityUsersQ.data) return;
|
||||
setSelectedUserIds(new Set(entityUsersQ.data.map((u) => u.id)));
|
||||
}, [entityUsersQ.data]);
|
||||
|
||||
const filteredPlatformUsers = useMemo(() => {
|
||||
const all = platformUsersQ.data ?? [];
|
||||
const q = usersSearch.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((u) =>
|
||||
(u.name || "").toLowerCase().includes(q)
|
||||
|| (u.email || "").toLowerCase().includes(q)
|
||||
|| (u.login || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [platformUsersQ.data, usersSearch]);
|
||||
|
||||
function openEdit(e: typeof entities[0]) {
|
||||
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function openManageUsers(e: typeof entities[0]) {
|
||||
setManageEntity({ id: e.id, name: e.name });
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
setManageOpen(true);
|
||||
}
|
||||
|
||||
function toggleUser(userId: number) {
|
||||
setSelectedUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -159,6 +226,11 @@ export default function EntitiesPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{isAdminUser ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openManageUsers(e)}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -279,6 +351,79 @@ export default function EntitiesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Manage Users Dialog (admin only) */}
|
||||
<Dialog open={manageOpen} onOpenChange={(open) => {
|
||||
setManageOpen(open);
|
||||
if (!open) {
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Users — {manageEntity?.name ?? ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!isAdminUser ? (
|
||||
<div className="text-sm text-muted-foreground py-4">Only admin users can manage entity members.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name/email/login..."
|
||||
className="pl-9"
|
||||
value={usersSearch}
|
||||
onChange={(e) => setUsersSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border max-h-[420px] overflow-y-auto">
|
||||
{platformUsersQ.isLoading || entityUsersQ.isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
|
||||
) : filteredPlatformUsers.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No users found.</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredPlatformUsers.map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-3 p-3 hover:bg-muted/40 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedUserIds.has(u.id)}
|
||||
onCheckedChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{u.name || u.login || u.email}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{u.email || u.login}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="text-xs text-muted-foreground mr-auto">
|
||||
{selectedUserIds.size} user(s) selected
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setManageOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!manageEntity || saveEntityUsersMut.isPending || !isAdminUser}
|
||||
onClick={() => {
|
||||
if (!manageEntity) return;
|
||||
saveEntityUsersMut.mutate({
|
||||
entityId: manageEntity.id,
|
||||
userIds: Array.from(selectedUserIds),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saveEntityUsersMut.isPending ? "Saving..." : "Save Users"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
useRenderAIPrompt,
|
||||
} from "@/hooks/queries/useAIPrompts";
|
||||
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
|
||||
import AIProviderSettings from "@/pages/admin/AIProviderSettings";
|
||||
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
|
||||
import type { AIPromptSummary } from "@/types/ai-prompt";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
History,
|
||||
KeyRound,
|
||||
Play,
|
||||
PlusCircle,
|
||||
Wrench,
|
||||
@@ -582,6 +584,13 @@ export default function AIPromptEditor() {
|
||||
<FileText className="me-1 h-4 w-4" />
|
||||
{t("aiAdmin.tabs.prompts", "Prompts")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="providers"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
|
||||
>
|
||||
<KeyRound className="me-1 h-4 w-4" />
|
||||
{t("aiAdmin.tabs.providers", "Providers & Keys")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="agents" className="mt-2">
|
||||
@@ -593,6 +602,9 @@ export default function AIPromptEditor() {
|
||||
<TabsContent value="prompts" className="mt-2">
|
||||
<AIPromptsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="providers" className="mt-2">
|
||||
<AIProviderSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
704
frontend/src/pages/admin/AIProviderSettings.tsx
Normal file
704
frontend/src/pages/admin/AIProviderSettings.tsx
Normal file
@@ -0,0 +1,704 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Save,
|
||||
Settings2,
|
||||
Volume2,
|
||||
Wand2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
aiSettingsService,
|
||||
type AISettingsPatchPayload,
|
||||
type AISettingsState,
|
||||
type CapabilityKey,
|
||||
type CapabilityState,
|
||||
type ProviderTestResult,
|
||||
} from "@/services/aiSettings.service";
|
||||
|
||||
/**
|
||||
* Admin UI for AI provider selection and API-key management.
|
||||
*
|
||||
* - Dropdowns set the active provider per capability (text / image / audio /
|
||||
* video). Selecting "auto" tells the backend to try paid providers first
|
||||
* and silently fall back to free providers on quota / billing errors.
|
||||
* - API-key inputs are write-only — the backend never returns a key value;
|
||||
* we only render a "saved" badge based on `keys_set[<name>]`.
|
||||
* - Changes persist to `ir.config_parameter` and take effect on the very
|
||||
* next request (no caching), so admins can flip OpenAI -> Mock without
|
||||
* restarting Odoo.
|
||||
*
|
||||
* Mounted as the fourth tab on `/admin/ai/prompts` so the user keeps a
|
||||
* single "AI" surface in the admin nav.
|
||||
*/
|
||||
|
||||
const CAPABILITY_META: Array<{
|
||||
key: CapabilityKey;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
i18nKey: string;
|
||||
defaultLabel: string;
|
||||
}> = [
|
||||
{ key: "text", icon: Wand2, i18nKey: "aiProviders.cap.text", defaultLabel: "Text generation" },
|
||||
{ key: "image", icon: ImageIcon, i18nKey: "aiProviders.cap.image", defaultLabel: "Image generation" },
|
||||
{ key: "audio", icon: Volume2, i18nKey: "aiProviders.cap.audio", defaultLabel: "Audio (TTS)" },
|
||||
{ key: "video", icon: Activity, i18nKey: "aiProviders.cap.video", defaultLabel: "Video composition" },
|
||||
];
|
||||
|
||||
interface KeyFieldDef {
|
||||
shortName: string;
|
||||
i18nKey: string;
|
||||
defaultLabel: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
hintI18nKey?: string;
|
||||
group: "openai" | "aws" | "elevenlabs" | "other" | "paymob";
|
||||
}
|
||||
|
||||
const KEY_FIELDS: KeyFieldDef[] = [
|
||||
// OpenAI
|
||||
{
|
||||
shortName: "openai_api_key",
|
||||
i18nKey: "aiProviders.keys.openai",
|
||||
defaultLabel: "OpenAI API key",
|
||||
placeholder: "sk-…",
|
||||
hintI18nKey: "aiProviders.keys.openai_hint",
|
||||
hint: "Used for GPT-4o, embeddings, and DALL-E 3.",
|
||||
group: "openai",
|
||||
},
|
||||
// AWS
|
||||
{
|
||||
shortName: "aws_access_key",
|
||||
i18nKey: "aiProviders.keys.aws_access",
|
||||
defaultLabel: "AWS Access Key ID",
|
||||
placeholder: "AKIA…",
|
||||
group: "aws",
|
||||
},
|
||||
{
|
||||
shortName: "aws_secret_key",
|
||||
i18nKey: "aiProviders.keys.aws_secret",
|
||||
defaultLabel: "AWS Secret Access Key",
|
||||
placeholder: "wJalr…",
|
||||
group: "aws",
|
||||
},
|
||||
// ElevenLabs
|
||||
{
|
||||
shortName: "elevenlabs_api_key",
|
||||
i18nKey: "aiProviders.keys.elevenlabs",
|
||||
defaultLabel: "ElevenLabs API key",
|
||||
placeholder: "el_…",
|
||||
group: "elevenlabs",
|
||||
},
|
||||
// GPTZero
|
||||
{
|
||||
shortName: "gptzero_api_key",
|
||||
i18nKey: "aiProviders.keys.gptzero",
|
||||
defaultLabel: "GPTZero API key",
|
||||
group: "other",
|
||||
},
|
||||
// Paymob (payments)
|
||||
{
|
||||
shortName: "paymob_api_key",
|
||||
i18nKey: "aiProviders.keys.paymob_api",
|
||||
defaultLabel: "Paymob API key",
|
||||
group: "paymob",
|
||||
},
|
||||
{
|
||||
shortName: "paymob_integration_id",
|
||||
i18nKey: "aiProviders.keys.paymob_integration",
|
||||
defaultLabel: "Paymob integration ID",
|
||||
group: "paymob",
|
||||
},
|
||||
{
|
||||
shortName: "paymob_iframe_id",
|
||||
i18nKey: "aiProviders.keys.paymob_iframe",
|
||||
defaultLabel: "Paymob iframe ID",
|
||||
group: "paymob",
|
||||
},
|
||||
{
|
||||
shortName: "paymob_hmac_secret",
|
||||
i18nKey: "aiProviders.keys.paymob_hmac",
|
||||
defaultLabel: "Paymob HMAC secret",
|
||||
group: "paymob",
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_GROUPS: Array<{
|
||||
group: KeyFieldDef["group"];
|
||||
i18nKey: string;
|
||||
defaultLabel: string;
|
||||
}> = [
|
||||
{ group: "openai", i18nKey: "aiProviders.group.openai", defaultLabel: "OpenAI" },
|
||||
{ group: "aws", i18nKey: "aiProviders.group.aws", defaultLabel: "AWS Polly" },
|
||||
{ group: "elevenlabs", i18nKey: "aiProviders.group.elevenlabs", defaultLabel: "ElevenLabs" },
|
||||
{ group: "other", i18nKey: "aiProviders.group.other", defaultLabel: "Other AI services" },
|
||||
{ group: "paymob", i18nKey: "aiProviders.group.paymob", defaultLabel: "Paymob (payments)" },
|
||||
];
|
||||
|
||||
const KIND_BADGE: Record<string, { className: string; labelKey: string; defaultLabel: string }> = {
|
||||
paid: { className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||
labelKey: "aiProviders.kind.paid", defaultLabel: "Paid" },
|
||||
free: { className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||
labelKey: "aiProviders.kind.free", defaultLabel: "Free" },
|
||||
auto: { className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
|
||||
labelKey: "aiProviders.kind.auto", defaultLabel: "Auto" },
|
||||
};
|
||||
|
||||
function CapabilityCard({
|
||||
capKey,
|
||||
state,
|
||||
onChange,
|
||||
onTest,
|
||||
testing,
|
||||
testResult,
|
||||
}: {
|
||||
capKey: CapabilityKey;
|
||||
state: CapabilityState;
|
||||
onChange: (newValue: string) => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
testResult: ProviderTestResult | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const meta = CAPABILITY_META.find((m) => m.key === capKey)!;
|
||||
const Icon = meta.icon;
|
||||
const activeOption = state.options.find((o) => o.value === state.active);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(meta.i18nKey, meta.defaultLabel)}
|
||||
</CardTitle>
|
||||
{activeOption ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={KIND_BADGE[activeOption.kind]?.className}
|
||||
>
|
||||
{t(
|
||||
KIND_BADGE[activeOption.kind]?.labelKey ?? "aiProviders.kind.auto",
|
||||
KIND_BADGE[activeOption.kind]?.defaultLabel ?? activeOption.kind,
|
||||
)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t(`${meta.i18nKey}_hint`, "Active provider for this capability.")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`provider-${capKey}`}>
|
||||
{t("aiProviders.activeProvider", "Active provider")}
|
||||
</Label>
|
||||
<Select value={state.active} onValueChange={onChange}>
|
||||
<SelectTrigger id={`provider-${capKey}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{state.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
{opt.label}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${KIND_BADGE[opt.kind]?.className ?? ""}`}
|
||||
>
|
||||
{t(
|
||||
KIND_BADGE[opt.kind]?.labelKey ?? "aiProviders.kind.auto",
|
||||
KIND_BADGE[opt.kind]?.defaultLabel ?? opt.kind,
|
||||
)}
|
||||
</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{state.paid_with_credentials.length === 0 && capKey !== "video" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"aiProviders.noPaidKeys",
|
||||
"No paid API keys configured for this capability — free fallback will be used.",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("aiProviders.paidConfigured", "Configured paid providers:")}{" "}
|
||||
{state.paid_with_credentials.join(", ") || "—"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="me-1 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
{t("aiProviders.testButton", "Test fallback chain")}
|
||||
</Button>
|
||||
{testResult ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{testResult.chain
|
||||
.map((c) => `${c.provider}${c.ok ? " ✓" : " ✗"}`)
|
||||
.join(" → ")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretField({
|
||||
field,
|
||||
isSet,
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
}: {
|
||||
field: KeyFieldDef;
|
||||
isSet: boolean;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const dirty = value !== undefined;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`key-${field.shortName}`} className="text-sm">
|
||||
{t(field.i18nKey, field.defaultLabel)}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSet && !dirty ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
||||
>
|
||||
<CheckCircle2 className="me-1 h-3 w-3" />
|
||||
{t("aiProviders.keys.saved", "Saved")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{dirty ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200"
|
||||
>
|
||||
{t("aiProviders.keys.unsaved", "Unsaved")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id={`key-${field.shortName}`}
|
||||
type={revealed ? "text" : "password"}
|
||||
placeholder={
|
||||
isSet
|
||||
? t("aiProviders.keys.placeholderSaved", "•••••• (click to replace)")
|
||||
: (field.placeholder ?? "")
|
||||
}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground absolute end-2 top-1/2 -translate-y-1/2"
|
||||
tabIndex={-1}
|
||||
aria-label={revealed ? "Hide value" : "Show value"}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{isSet ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
>
|
||||
{t("aiProviders.keys.clear", "Clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{field.hint ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(field.hintI18nKey ?? "", field.hint)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlainField({
|
||||
shortName,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
shortName: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`plain-${shortName}`} className="text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`plain-${shortName}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIProviderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<AISettingsState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Pending diff applied on Save. Provider edits are confirmed
|
||||
// immediately so the UI reflects the active selection — but we still
|
||||
// batch them into one PATCH so we don't burn requests on every click.
|
||||
const [pendingProviders, setPendingProviders] = useState<
|
||||
Partial<Record<CapabilityKey, string>>
|
||||
>({});
|
||||
const [pendingKeys, setPendingKeys] = useState<Record<string, string>>({});
|
||||
const [pendingPlain, setPendingPlain] = useState<Record<string, string>>({});
|
||||
|
||||
const [testing, setTesting] = useState<CapabilityKey | null>(null);
|
||||
const [testResults, setTestResults] = useState<
|
||||
Partial<Record<CapabilityKey, ProviderTestResult>>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await aiSettingsService.get();
|
||||
setState(data);
|
||||
setPendingProviders({});
|
||||
setPendingKeys({});
|
||||
setPendingPlain({});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(t("aiProviders.toast.loadFailed", "Could not load AI settings"), {
|
||||
description: msg,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = useMemo(
|
||||
() =>
|
||||
Object.keys(pendingProviders).length > 0 ||
|
||||
Object.keys(pendingKeys).length > 0 ||
|
||||
Object.keys(pendingPlain).length > 0,
|
||||
[pendingProviders, pendingKeys, pendingPlain],
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (!state || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: AISettingsPatchPayload = {};
|
||||
if (Object.keys(pendingProviders).length) payload.providers = pendingProviders;
|
||||
if (Object.keys(pendingKeys).length) payload.keys = pendingKeys;
|
||||
if (Object.keys(pendingPlain).length) payload.plain = pendingPlain;
|
||||
const updated = await aiSettingsService.update(payload);
|
||||
setState(updated);
|
||||
setPendingProviders({});
|
||||
setPendingKeys({});
|
||||
setPendingPlain({});
|
||||
toast.success(
|
||||
t("aiProviders.toast.saved", "AI provider settings saved"),
|
||||
{
|
||||
description: t(
|
||||
"aiProviders.toast.savedDescription",
|
||||
"Active providers updated. Changes apply to the next request.",
|
||||
),
|
||||
},
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(t("aiProviders.toast.saveFailed", "Could not save settings"), {
|
||||
description: msg,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(cap: CapabilityKey) {
|
||||
setTesting(cap);
|
||||
try {
|
||||
const result = await aiSettingsService.test(cap);
|
||||
setTestResults((prev) => ({ ...prev, [cap]: result }));
|
||||
const allOk = result.chain.every((c) => c.ok);
|
||||
if (allOk) {
|
||||
toast.success(
|
||||
t("aiProviders.toast.testOk", "Provider chain ready") +
|
||||
` · ${result.chain.map((c) => c.provider).join(" → ")}`,
|
||||
);
|
||||
} else {
|
||||
toast.warning(
|
||||
t("aiProviders.toast.testPartial", "Some providers missing credentials") +
|
||||
` · ${result.chain
|
||||
.map((c) => `${c.provider}${c.ok ? "✓" : "✗"}`)
|
||||
.join(" → ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(t("aiProviders.toast.testFailed", "Provider test failed"), {
|
||||
description: msg,
|
||||
});
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
{t("aiProviders.empty", "Settings could not be loaded.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveProviders: Record<CapabilityKey, CapabilityState> = {
|
||||
text: { ...state.providers.text, active: pendingProviders.text ?? state.providers.text.active },
|
||||
image: { ...state.providers.image, active: pendingProviders.image ?? state.providers.image.active },
|
||||
audio: { ...state.providers.audio, active: pendingProviders.audio ?? state.providers.audio.active },
|
||||
video: { ...state.providers.video, active: pendingProviders.video ?? state.providers.video.active },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
{t("aiProviders.title", "AI Providers & API Keys")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"aiProviders.subtitle",
|
||||
"Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="shrink-0"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="me-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="me-1 h-4 w-4" />
|
||||
)}
|
||||
{t("aiProviders.save", "Save settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{CAPABILITY_META.map((meta) => (
|
||||
<CapabilityCard
|
||||
key={meta.key}
|
||||
capKey={meta.key}
|
||||
state={effectiveProviders[meta.key]}
|
||||
onChange={(newValue) =>
|
||||
setPendingProviders((prev) => ({ ...prev, [meta.key]: newValue }))
|
||||
}
|
||||
onTest={() => handleTest(meta.key)}
|
||||
testing={testing === meta.key}
|
||||
testResult={testResults[meta.key] ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("aiProviders.keys.title", "API keys")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"aiProviders.keys.subtitle",
|
||||
"Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{KEY_GROUPS.map((group) => {
|
||||
const fields = KEY_FIELDS.filter((f) => f.group === group.group);
|
||||
if (!fields.length) return null;
|
||||
return (
|
||||
<div key={group.group} className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(group.i18nKey, group.defaultLabel)}
|
||||
</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<SecretField
|
||||
key={field.shortName}
|
||||
field={field}
|
||||
isSet={Boolean(state.keys_set[field.shortName])}
|
||||
value={pendingKeys[field.shortName]}
|
||||
onChange={(v) =>
|
||||
setPendingKeys((prev) => ({
|
||||
...prev,
|
||||
[field.shortName]: v,
|
||||
}))
|
||||
}
|
||||
onClear={() =>
|
||||
setPendingKeys((prev) => ({
|
||||
...prev,
|
||||
[field.shortName]: "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("aiProviders.plain.title", "Model & runtime")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"aiProviders.plain.subtitle",
|
||||
"Non-secret runtime parameters: default model, AWS region, request timeout.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<PlainField
|
||||
shortName="openai_model"
|
||||
label={t("aiProviders.plain.openai_model", "OpenAI model")}
|
||||
value={pendingPlain.openai_model ?? state.plain.openai_model ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, openai_model: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="openai_fast_model"
|
||||
label={t("aiProviders.plain.openai_fast", "OpenAI fast model")}
|
||||
value={pendingPlain.openai_fast_model ?? state.plain.openai_fast_model ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, openai_fast_model: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="aws_region"
|
||||
label={t("aiProviders.plain.aws_region", "AWS region")}
|
||||
value={pendingPlain.aws_region ?? state.plain.aws_region ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, aws_region: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="elevenlabs_model"
|
||||
label={t("aiProviders.plain.elevenlabs_model", "ElevenLabs model")}
|
||||
value={pendingPlain.elevenlabs_model ?? state.plain.elevenlabs_model ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, elevenlabs_model: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="request_timeout"
|
||||
label={t("aiProviders.plain.timeout", "Request timeout (seconds)")}
|
||||
value={pendingPlain.request_timeout ?? state.plain.request_timeout ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, request_timeout: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="max_retries"
|
||||
label={t("aiProviders.plain.max_retries", "Max retries")}
|
||||
value={pendingPlain.max_retries ?? state.plain.max_retries ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, max_retries: v }))
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"aiProviders.footer",
|
||||
"Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Upload, Search, FileText, Video, Link2, Download, Trash2,
|
||||
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
|
||||
Tag, Plus, Pencil, X, CalendarDays,
|
||||
Tag, Plus, Pencil, X, CalendarDays, Eye,
|
||||
} from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import { coursewareService } from "@/services/courseware.service";
|
||||
import { taxonomyService } from "@/services/taxonomy.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
||||
import type { Resource, ResourceTag } from "@/types";
|
||||
@@ -165,13 +165,13 @@ function EditResourceDialog({
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="document">Document</SelectItem>
|
||||
<SelectItem value="interactive">Interactive</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="document">Document</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="interactive">Interactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -351,12 +351,19 @@ export default function ResourceManager() {
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="document">Document</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="interactive">Interactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Tip: leave this on "PDF" — the server auto-detects the
|
||||
actual type from the uploaded file's MIME and corrects it.
|
||||
</p>
|
||||
</div>
|
||||
<TaxonomyCascade
|
||||
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
|
||||
@@ -619,12 +626,46 @@ function ContentTable({
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{row.source === "resource" && row.resourceId && (
|
||||
{row.source === "resource" && row.resourceId && row.raw && (
|
||||
<>
|
||||
{/* Smart preview — opens the file inline (PDF in
|
||||
iframe, image / audio / video in their native
|
||||
tag) without forcing a download. ``link`` rows
|
||||
jump to the external URL directly. */}
|
||||
{(row.raw.preview_url || row.raw.url) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Preview"
|
||||
onClick={() => {
|
||||
const target =
|
||||
row.raw?.preview_url
|
||||
? withAuthQuery(row.raw.preview_url)
|
||||
: row.raw?.url || "";
|
||||
if (target) window.open(target, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||
try { const blob = await resourcesService.download(row.resourceId!); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||
}}><Download className="h-4 w-4" /></Button>
|
||||
{row.raw.has_file && (
|
||||
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||
try {
|
||||
const { blob, filename } = await resourcesService.download(row.resourceId!);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
// Prefer the server-provided filename (which always
|
||||
// carries the extension) over ``row.name``, which
|
||||
// is often just "test" — the previous behaviour
|
||||
// saved files with no extension on disk.
|
||||
a.download = filename || row.raw?.original_filename || row.raw?.file_name || row.name || "resource";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||
}}><Download className="h-4 w-4" /></Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Library,
|
||||
Link2,
|
||||
Mic,
|
||||
Music,
|
||||
Trash2,
|
||||
Upload,
|
||||
Video,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,9 +29,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { CoursePlanGenerateBrief } from "@/types";
|
||||
import type { CoursePlanGenerateBrief, Resource } from "@/types";
|
||||
|
||||
/**
|
||||
* AI course-plan generation wizard.
|
||||
@@ -38,6 +50,37 @@ import type { CoursePlanGenerateBrief } from "@/types";
|
||||
* materials immediately.
|
||||
*/
|
||||
|
||||
type DraftSourceKind = "file" | "url" | "text" | "resource";
|
||||
|
||||
interface DraftSource {
|
||||
/** Stable client-side id; not persisted. */
|
||||
uid: string;
|
||||
kind: DraftSourceKind;
|
||||
name: string;
|
||||
/** Only set when kind === "file". */
|
||||
file?: File;
|
||||
/** Only set when kind === "url". */
|
||||
url?: string;
|
||||
/** Only set when kind === "text". */
|
||||
text?: string;
|
||||
/** Only set when kind === "resource" — pointer into the central library. */
|
||||
resourceId?: number;
|
||||
/** Display-only metadata so we can render the row without a refetch. */
|
||||
resourceType?: string;
|
||||
}
|
||||
|
||||
interface MediaToggleState {
|
||||
/** Generate narration audio (Polly / ElevenLabs) for listening +
|
||||
* speaking materials right after each week's materials are produced. */
|
||||
audio: boolean;
|
||||
/** Generate DALL-E images for reading texts, listening scripts, and
|
||||
* vocabulary lists. Bound to the per-plan image budget. */
|
||||
image: boolean;
|
||||
/** Compose audio + image into a slideshow MP4 with ffmpeg. Slowest
|
||||
* option; off by default. */
|
||||
video: boolean;
|
||||
}
|
||||
|
||||
interface CoursePlanWizardState {
|
||||
title: string;
|
||||
cefr_level: string;
|
||||
@@ -48,6 +91,8 @@ interface CoursePlanWizardState {
|
||||
grammar_focus: string[];
|
||||
resources: string[];
|
||||
notes: string;
|
||||
sources: DraftSource[];
|
||||
media: MediaToggleState;
|
||||
}
|
||||
|
||||
const CEFR_OPTIONS = [
|
||||
@@ -183,6 +228,17 @@ export default function CoursePlanWizard() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sources",
|
||||
titleKey: "coursePlan.wizard.steps.sources",
|
||||
descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
|
||||
render: ({ state, update }) => (
|
||||
<SourcesStep
|
||||
sources={state.sources}
|
||||
onChange={(sources) => update({ sources })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
titleKey: "coursePlan.wizard.steps.scope",
|
||||
@@ -214,6 +270,17 @@ export default function CoursePlanWizard() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "media",
|
||||
titleKey: "coursePlan.wizard.steps.media",
|
||||
descriptionKey: "coursePlan.wizard.steps.mediaDesc",
|
||||
render: ({ state, update }) => (
|
||||
<MediaStep
|
||||
media={state.media}
|
||||
onChange={(media) => update({ media })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
titleKey: "coursePlan.wizard.steps.review",
|
||||
@@ -257,6 +324,26 @@ export default function CoursePlanWizard() {
|
||||
label={t("coursePlan.wizard.fields.notes")}
|
||||
value={state.notes || "—"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.steps.sources")}
|
||||
value={
|
||||
state.sources.length
|
||||
? t("coursePlan.wizard.review.sourcesCount", {
|
||||
count: state.sources.length,
|
||||
})
|
||||
: t("coursePlan.wizard.review.noSources")
|
||||
}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.steps.media")}
|
||||
value={[
|
||||
state.media.audio ? t("coursePlan.media.audio") : null,
|
||||
state.media.image ? t("coursePlan.media.image") : null,
|
||||
state.media.video ? t("coursePlan.media.video") : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || t("coursePlan.wizard.review.mediaNone")}
|
||||
/>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("coursePlan.wizard.reviewHint")}
|
||||
</div>
|
||||
@@ -283,9 +370,11 @@ export default function CoursePlanWizard() {
|
||||
grammar_focus: [],
|
||||
resources: [],
|
||||
notes: "",
|
||||
sources: [],
|
||||
media: { audio: true, image: true, video: false },
|
||||
}}
|
||||
onFinish={async (state) => {
|
||||
await mutation.mutateAsync({
|
||||
const resp = await mutation.mutateAsync({
|
||||
title: state.title.trim(),
|
||||
cefr_level: state.cefr_level,
|
||||
total_weeks: state.total_weeks,
|
||||
@@ -298,11 +387,368 @@ export default function CoursePlanWizard() {
|
||||
resources: state.resources.length ? state.resources : undefined,
|
||||
notes: state.notes.trim() || undefined,
|
||||
});
|
||||
const planId = resp?.data?.id;
|
||||
if (planId && state.sources.length) {
|
||||
// Library picks go through the dedicated bulk endpoint — one
|
||||
// round-trip for the whole set, with server-side dedupe — so we
|
||||
// collect their ids first and post them together. The remaining
|
||||
// file/url/text drafts are posted individually as before.
|
||||
const resourceIds = state.sources
|
||||
.filter((s) => s.kind === "resource" && s.resourceId)
|
||||
.map((s) => s.resourceId as number);
|
||||
if (resourceIds.length) {
|
||||
try {
|
||||
await coursePlanService.attachResources(planId, resourceIds);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
describeApiError(
|
||||
err,
|
||||
t("coursePlan.sources.libraryAttachFailed"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort upload — we keep going even if a single one fails so
|
||||
// the user lands on the detail page with whatever did make it in.
|
||||
for (const src of state.sources) {
|
||||
try {
|
||||
if (src.kind === "file" && src.file) {
|
||||
await coursePlanService.uploadSource(planId, src.file, {
|
||||
name: src.name || src.file.name,
|
||||
});
|
||||
} else if (src.kind === "url" && src.url) {
|
||||
await coursePlanService.createSource(planId, {
|
||||
kind: "url",
|
||||
url: src.url,
|
||||
name: src.name || src.url,
|
||||
});
|
||||
} else if (src.kind === "text" && src.text) {
|
||||
await coursePlanService.createSource(planId, {
|
||||
kind: "text",
|
||||
inline_text: src.text,
|
||||
name: src.name || t("coursePlan.sourceKind.text"),
|
||||
});
|
||||
}
|
||||
// src.kind === "resource" was already handled in the bulk
|
||||
// attach above.
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
describeApiError(err, t("coursePlan.sources.uploadFailed", {
|
||||
name: src.name || src.kind,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function genUid() {
|
||||
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
function SourcesStep({
|
||||
sources,
|
||||
onChange,
|
||||
}: {
|
||||
sources: DraftSource[];
|
||||
onChange: (next: DraftSource[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [draftUrl, setDraftUrl] = useState("");
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
|
||||
// Resource ids already queued in this wizard session; passed to the
|
||||
// picker so the same resource can't be added twice.
|
||||
const queuedResourceIds = new Set(
|
||||
sources
|
||||
.filter((s) => s.kind === "resource" && s.resourceId)
|
||||
.map((s) => s.resourceId as number),
|
||||
);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (!files || !files.length) return;
|
||||
const additions: DraftSource[] = [];
|
||||
for (const f of Array.from(files)) {
|
||||
additions.push({
|
||||
uid: genUid(),
|
||||
kind: "file",
|
||||
name: f.name,
|
||||
file: f,
|
||||
});
|
||||
}
|
||||
onChange([...sources, ...additions]);
|
||||
};
|
||||
|
||||
const addUrl = () => {
|
||||
const url = draftUrl.trim();
|
||||
if (!url) return;
|
||||
onChange([...sources, { uid: genUid(), kind: "url", name: url, url }]);
|
||||
setDraftUrl("");
|
||||
};
|
||||
|
||||
const addText = () => {
|
||||
const text = draftText.trim();
|
||||
if (!text) return;
|
||||
onChange([
|
||||
...sources,
|
||||
{
|
||||
uid: genUid(),
|
||||
kind: "text",
|
||||
name: text.slice(0, 60).replace(/\s+/g, " "),
|
||||
text,
|
||||
},
|
||||
]);
|
||||
setDraftText("");
|
||||
};
|
||||
|
||||
const addLibraryResources = (picked: Resource[]) => {
|
||||
if (!picked.length) return;
|
||||
const additions: DraftSource[] = picked.map((r) => ({
|
||||
uid: genUid(),
|
||||
kind: "resource",
|
||||
name: r.name,
|
||||
resourceId: r.id,
|
||||
resourceType: r.resource_type || r.type || "resource",
|
||||
}));
|
||||
onChange([...sources, ...additions]);
|
||||
setLibraryOpen(false);
|
||||
};
|
||||
|
||||
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{t("coursePlan.sources.dropTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("coursePlan.sources.dropHint")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
|
||||
onChange={(e) => {
|
||||
addFiles(e.currentTarget.files);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.sources.uploadFiles")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||
<Library className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{t("coursePlan.sources.libraryPickTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("coursePlan.sources.libraryPickHint")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
>
|
||||
<Library className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.sources.libraryPickButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>{t("coursePlan.sources.urlLabel")}</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
value={draftUrl}
|
||||
onChange={(e) => setDraftUrl(e.target.value)}
|
||||
placeholder="https://example.com/article"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addUrl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addUrl}
|
||||
disabled={!draftUrl.trim()}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("coursePlan.sources.textLabel")}</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
placeholder={t("coursePlan.sources.textPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addText}
|
||||
disabled={!draftText.trim()}
|
||||
className="self-start"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sources.length > 0 && (
|
||||
<div className="rounded-md border">
|
||||
<div className="px-3 py-2 border-b bg-muted/30 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{t("coursePlan.sources.collected", { count: sources.length })}
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{sources.map((s) => (
|
||||
<li
|
||||
key={s.uid}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<Badge variant="outline" className="capitalize text-[10px]">
|
||||
{s.kind === "resource"
|
||||
? s.resourceType || "library"
|
||||
: s.kind}
|
||||
</Badge>
|
||||
{s.kind === "resource" && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-[10px] flex items-center"
|
||||
>
|
||||
<Library className="h-3 w-3" />
|
||||
{t("coursePlan.sources.fromLibrary")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="flex-1 truncate">{s.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => remove(s.uid)}
|
||||
aria-label={t("common.remove", "Remove")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LibraryPickerDialog
|
||||
open={libraryOpen}
|
||||
onOpenChange={setLibraryOpen}
|
||||
alreadyLinkedIds={queuedResourceIds}
|
||||
onConfirm={addLibraryResources}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaStep({
|
||||
media,
|
||||
onChange,
|
||||
}: {
|
||||
media: MediaToggleState;
|
||||
onChange: (next: MediaToggleState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<MediaToggleCard
|
||||
icon={Music}
|
||||
title={t("coursePlan.media.audioTitle")}
|
||||
description={t("coursePlan.media.audioDesc")}
|
||||
checked={media.audio}
|
||||
onToggle={(v) => onChange({ ...media, audio: v })}
|
||||
/>
|
||||
<MediaToggleCard
|
||||
icon={ImageIcon}
|
||||
title={t("coursePlan.media.imageTitle")}
|
||||
description={t("coursePlan.media.imageDesc")}
|
||||
checked={media.image}
|
||||
onToggle={(v) => onChange({ ...media, image: v })}
|
||||
/>
|
||||
<MediaToggleCard
|
||||
icon={Video}
|
||||
title={t("coursePlan.media.videoTitle")}
|
||||
description={t("coursePlan.media.videoDesc")}
|
||||
checked={media.video}
|
||||
onToggle={(v) => onChange({ ...media, video: v })}
|
||||
/>
|
||||
<div className="sm:col-span-3 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
{t("coursePlan.media.hint")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaToggleCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: typeof Music;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onToggle: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!checked)}
|
||||
className={[
|
||||
"rounded-md border p-3 text-left transition-colors",
|
||||
checked
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/40",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<div className="font-medium text-sm">{title}</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
|
||||
@@ -40,10 +40,23 @@ function buildAnswerMap(sections: ExamSessionSection[]) {
|
||||
const map = new Map<number, ExamAnswer>();
|
||||
for (const sec of sections) {
|
||||
for (const q of sec.questions) {
|
||||
// The backend `/api/exam/<id>/session` endpoint returns a
|
||||
// `saved_answer` per question whenever a previous attempt is
|
||||
// resumed (browser refresh, network drop, accidental tab-close).
|
||||
// Seed the local map from that value so the student picks up
|
||||
// exactly where they left off — without this, autosaved answers
|
||||
// were silently dropped on every reload.
|
||||
const saved = (q as unknown as { saved_answer?: unknown }).saved_answer;
|
||||
const flagged = Boolean(
|
||||
(q as unknown as { flagged?: boolean }).flagged,
|
||||
);
|
||||
map.set(q.id, {
|
||||
question_id: q.id,
|
||||
answer: null,
|
||||
flagged: false,
|
||||
answer:
|
||||
saved === undefined || saved === null
|
||||
? null
|
||||
: (saved as ExamAnswer["answer"]),
|
||||
flagged,
|
||||
time_spent_ms: 0,
|
||||
});
|
||||
}
|
||||
|
||||
347
frontend/src/pages/student/StudentCoursePlanDetail.tsx
Normal file
347
frontend/src/pages/student/StudentCoursePlanDetail.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Headphones,
|
||||
Image as ImageIcon,
|
||||
Library,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Music,
|
||||
PenSquare,
|
||||
Sparkles,
|
||||
Type,
|
||||
Video,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||
import MaterialBookView, { SkillBadge } from "@/components/coursePlan/MaterialBookView";
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanMaterial,
|
||||
CoursePlanMedia,
|
||||
CoursePlanWeek,
|
||||
} from "@/types";
|
||||
|
||||
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||
reading: BookOpen,
|
||||
writing: PenSquare,
|
||||
listening: Headphones,
|
||||
speaking: Mic,
|
||||
grammar: Type,
|
||||
vocabulary: Library,
|
||||
integrated: MessageSquare,
|
||||
};
|
||||
|
||||
/**
|
||||
* Student detail view for an assigned AI course plan.
|
||||
*
|
||||
* Server-side authorisation: the `/api/student/course-plans/:id` endpoint
|
||||
* returns 403 unless the current user is in the plan's assignment list,
|
||||
* so we can render unconditionally as soon as the query resolves.
|
||||
*
|
||||
* The page is intentionally read-only — students can play audio, view
|
||||
* images, and watch video, but they can't (re)generate content.
|
||||
*/
|
||||
export default function StudentCoursePlanDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { planId: planIdRaw } = useParams<{ planId: string }>();
|
||||
const planId = Number(planIdRaw);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["student-course-plan", planId],
|
||||
queryFn: () => coursePlanService.studentGet(planId),
|
||||
enabled: Number.isFinite(planId) && planId > 0,
|
||||
});
|
||||
|
||||
const plan = data?.data as CoursePlan | undefined;
|
||||
|
||||
const materialsByWeek = useMemo(() => {
|
||||
const map = new Map<number, CoursePlanMaterial[]>();
|
||||
if (!plan?.materials) return map;
|
||||
for (const m of plan.materials) {
|
||||
const list = map.get(m.week_number) ?? [];
|
||||
list.push(m);
|
||||
map.set(m.week_number, list);
|
||||
}
|
||||
return map;
|
||||
}, [plan?.materials]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||
<Link to="/student/course-plans" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("coursePlan.student.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-40" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||
{plan.description && (
|
||||
<CardDescription className="max-w-3xl">
|
||||
{plan.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="uppercase">
|
||||
{plan.cefr_level}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.hoursPerWeek", {
|
||||
count: plan.contact_hours_per_week,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.assignment && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{plan.assignment.due_date
|
||||
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||
: t("coursePlan.student.noDue")}
|
||||
{plan.assignment.assigned_by_name && (
|
||||
<span>
|
||||
·{" "}
|
||||
{t("coursePlan.student.assignedBy", {
|
||||
name: plan.assignment.assigned_by_name,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{plan.assignment?.message && (
|
||||
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||
{plan.assignment.message}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{plan.objectives.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.objectives")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
{plan.objectives.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{plan.weeks && plan.weeks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.delivery")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{plan.weeks.map((w) => (
|
||||
<StudentWeek
|
||||
key={w.id}
|
||||
week={w}
|
||||
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentWeek({
|
||||
week,
|
||||
materials,
|
||||
}: {
|
||||
week: CoursePlanWeek;
|
||||
materials: CoursePlanMaterial[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||
const skills = useMemo(
|
||||
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||
[materials],
|
||||
);
|
||||
const filteredMaterials = useMemo(
|
||||
() => materials.filter((m) => skillFilter === "all" || m.skill === skillFilter),
|
||||
[materials, skillFilter],
|
||||
);
|
||||
return (
|
||||
<AccordionItem value={String(week.week_number)}>
|
||||
<AccordionTrigger className="text-left">
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("coursePlan.weekN", { n: week.week_number })}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{week.focus || week.unit || "—"}
|
||||
</div>
|
||||
{week.date_label && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{week.date_label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={skillFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter("all")}
|
||||
>
|
||||
{t("common.all", "All")}
|
||||
</Button>
|
||||
{skills.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={skillFilter === s ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter(s)}
|
||||
className="capitalize"
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{materials.length === 0 && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("coursePlan.media.noMedia")}
|
||||
</p>
|
||||
)}
|
||||
{filteredMaterials.map((m) => (
|
||||
<StudentMaterial key={m.id} material={m} />
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<CardTitle className="text-base flex-1 min-w-0">
|
||||
{material.title}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(
|
||||
`coursePlan.materialType.${material.material_type}`,
|
||||
material.material_type,
|
||||
)}
|
||||
</Badge>
|
||||
<SkillBadge skill={material.skill} />
|
||||
</div>
|
||||
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
||||
{material.share_date && (
|
||||
<CardDescription>
|
||||
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(material.media ?? []).map((m) => (
|
||||
<StudentMediaTile key={m.id} media={m} />
|
||||
))}
|
||||
<MaterialBookView material={material} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||
<span className="capitalize text-xs text-muted-foreground">
|
||||
{media.kind}
|
||||
</span>
|
||||
</div>
|
||||
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||
{media.kind === "image" && (
|
||||
<img
|
||||
src={url}
|
||||
alt={media.title}
|
||||
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||
/>
|
||||
)}
|
||||
{media.kind === "video" && (
|
||||
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend/src/pages/student/StudentCoursePlans.tsx
Normal file
147
frontend/src/pages/student/StudentCoursePlans.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { CoursePlan } from "@/types";
|
||||
|
||||
/**
|
||||
* Student-facing list of AI course plans assigned to the current user.
|
||||
*
|
||||
* Reads from `GET /api/student/course-plans`, which only returns plans
|
||||
* the user has been linked to either directly (Phase D `students` mode)
|
||||
* or via the batch they're enrolled in. The card itself is intentionally
|
||||
* lightweight: tap → drill into `/student/course-plans/:id` for the full
|
||||
* weekly plan and embedded media.
|
||||
*/
|
||||
export default function StudentCoursePlans() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["student-course-plans"],
|
||||
queryFn: () => coursePlanService.studentList(),
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-bold">{t("coursePlan.student.listTitle")}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl">
|
||||
{t("coursePlan.student.listSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-44 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/20">
|
||||
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("coursePlan.student.empty")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((plan) => (
|
||||
<PlanCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({ plan }: { plan: CoursePlan }) {
|
||||
const { t } = useTranslation();
|
||||
const a = plan.assignment;
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base flex-1 min-w-0 line-clamp-2">
|
||||
{plan.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="uppercase shrink-0">
|
||||
{plan.cefr_level}
|
||||
</Badge>
|
||||
</div>
|
||||
{plan.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{plan.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.hoursPerWeek", {
|
||||
count: plan.contact_hours_per_week,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
{a && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
{a.assigned_by_name && (
|
||||
<div>
|
||||
{t("coursePlan.student.assignedBy", { name: a.assigned_by_name })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{a.due_date
|
||||
? t("coursePlan.student.due", { date: a.due_date })
|
||||
: t("coursePlan.student.noDue")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto pt-2">
|
||||
<Button asChild size="sm" className="w-full">
|
||||
<Link to={`/student/course-plans/${plan.id}`}>
|
||||
{t("coursePlan.student.open")}
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -440,9 +440,9 @@ function ResourceTable({
|
||||
<>
|
||||
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||
try {
|
||||
const blob = await resourcesService.download(row.resourceId!);
|
||||
const { blob, filename } = await resourcesService.download(row.resourceId!);
|
||||
const url = URL.createObjectURL(blob); const a = document.createElement("a");
|
||||
a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url);
|
||||
a.href = url; a.download = filename || row.name || "resource"; a.click(); URL.revokeObjectURL(url);
|
||||
} catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||
}}>
|
||||
<Download className="h-4 w-4" />
|
||||
|
||||
82
frontend/src/services/aiSettings.service.ts
Normal file
82
frontend/src/services/aiSettings.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
/**
|
||||
* AI provider & API-key settings client.
|
||||
*
|
||||
* Backed by `backend/custom_addons/encoach_ai/controllers/ai_settings_controller.py`
|
||||
* (`/api/ai/settings/providers`). API keys are write-only — the GET response
|
||||
* only ever exposes `<key>_set: boolean` markers, never the key itself.
|
||||
*
|
||||
* Provider switches take effect on the very next request (no caching),
|
||||
* so the LangGraph runtime instantly picks up the new selection.
|
||||
*/
|
||||
|
||||
export type CapabilityKey = "text" | "image" | "audio" | "video";
|
||||
export type ProviderKind = "paid" | "free" | "auto";
|
||||
|
||||
export interface ProviderOption {
|
||||
value: string;
|
||||
label: string;
|
||||
kind: ProviderKind;
|
||||
}
|
||||
|
||||
export interface CapabilityState {
|
||||
active: string;
|
||||
options: ProviderOption[];
|
||||
paid_with_credentials: string[];
|
||||
}
|
||||
|
||||
export interface AISettingsState {
|
||||
providers: Record<CapabilityKey, CapabilityState>;
|
||||
/** Boolean markers — `true` means a value is stored, never the value itself. */
|
||||
keys_set: Record<string, boolean>;
|
||||
/** Plain (non-secret) parameters, e.g. region, model name. */
|
||||
plain: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProviderTestResult {
|
||||
capability: CapabilityKey;
|
||||
active: string;
|
||||
chain: { provider: string; ok: boolean; note: string }[];
|
||||
}
|
||||
|
||||
export interface AISettingsPatchPayload {
|
||||
/** Active provider per capability. */
|
||||
providers?: Partial<Record<CapabilityKey, string>>;
|
||||
/**
|
||||
* API keys keyed by short name (`openai_api_key`, `aws_access_key`,
|
||||
* `aws_secret_key`, `aws_region`, `elevenlabs_api_key`,
|
||||
* `gptzero_api_key`, `paymob_api_key`, `paymob_integration_id`,
|
||||
* `paymob_iframe_id`, `paymob_hmac_secret`).
|
||||
*
|
||||
* - Sending an empty string clears the key.
|
||||
* - Omitting the field leaves it unchanged.
|
||||
*/
|
||||
keys?: Record<string, string>;
|
||||
/** Plain (non-secret) values like `aws_region`, `openai_model`. */
|
||||
plain?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const aiSettingsService = {
|
||||
async get(): Promise<AISettingsState> {
|
||||
const resp = await api.get<{ data: AISettingsState }>(
|
||||
"/ai/settings/providers",
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
|
||||
async update(payload: AISettingsPatchPayload): Promise<AISettingsState> {
|
||||
const resp = await api.patch<{ data: AISettingsState }>(
|
||||
"/ai/settings/providers",
|
||||
payload,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
|
||||
async test(capability: CapabilityKey): Promise<ProviderTestResult> {
|
||||
return api.post<ProviderTestResult>(
|
||||
"/ai/settings/providers/test",
|
||||
{ capability },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,13 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanAssignment,
|
||||
CoursePlanDeliverables,
|
||||
CoursePlanGenerateBrief,
|
||||
CoursePlanMaterial,
|
||||
CoursePlanMedia,
|
||||
CoursePlanSource,
|
||||
CoursePlanSourceKind,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
@@ -12,6 +17,9 @@ import type {
|
||||
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
|
||||
*/
|
||||
export const coursePlanService = {
|
||||
// ---------------------------------------------------------------------
|
||||
// Plan lifecycle
|
||||
// ---------------------------------------------------------------------
|
||||
async list(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
@@ -45,4 +53,208 @@ export const coursePlanService = {
|
||||
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
|
||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||
},
|
||||
|
||||
async updateMaterial(
|
||||
materialId: number,
|
||||
payload: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
body?: Record<string, unknown>;
|
||||
body_text?: string;
|
||||
share_date?: string | null;
|
||||
is_static?: boolean;
|
||||
},
|
||||
): Promise<{ data: CoursePlanMaterial }> {
|
||||
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase A — Sources
|
||||
// ---------------------------------------------------------------------
|
||||
async listSources(planId: number): Promise<{ items: CoursePlanSource[]; count: number }> {
|
||||
return api.get(`/ai/course-plan/${planId}/sources`);
|
||||
},
|
||||
|
||||
async createSource(
|
||||
planId: number,
|
||||
payload:
|
||||
| { kind: "url"; url: string; name?: string; auto_index?: boolean }
|
||||
| { kind: "text"; inline_text: string; name?: string; auto_index?: boolean },
|
||||
): Promise<{ data: CoursePlanSource }> {
|
||||
return api.post(`/ai/course-plan/${planId}/sources`, payload);
|
||||
},
|
||||
|
||||
async uploadSource(
|
||||
planId: number,
|
||||
file: File,
|
||||
opts?: { name?: string; auto_index?: boolean },
|
||||
): Promise<{ data: CoursePlanSource }> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("kind", "file" satisfies CoursePlanSourceKind);
|
||||
if (opts?.name) fd.append("name", opts.name);
|
||||
if (opts?.auto_index !== undefined) {
|
||||
fd.append("auto_index", opts.auto_index ? "1" : "0");
|
||||
}
|
||||
return api.upload(`/ai/course-plan/${planId}/sources`, fd);
|
||||
},
|
||||
|
||||
async reindexSource(
|
||||
planId: number,
|
||||
sourceId: number,
|
||||
): Promise<{ data: CoursePlanSource }> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/sources/${sourceId}/index`,
|
||||
);
|
||||
},
|
||||
|
||||
async deleteSource(
|
||||
planId: number,
|
||||
sourceId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach existing items from /admin/resources to a course plan as RAG
|
||||
* sources. Returns the rows that were newly attached plus the ids
|
||||
* that were skipped (already linked) or missing (deleted from the
|
||||
* library) so the caller can show a clear toast — e.g. "Attached 2,
|
||||
* skipped 1 already-linked".
|
||||
*/
|
||||
async attachResources(
|
||||
planId: number,
|
||||
resourceIds: number[],
|
||||
): Promise<{
|
||||
attached: CoursePlanSource[];
|
||||
skipped_existing: number[];
|
||||
missing: number[];
|
||||
count: number;
|
||||
}> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/sources/from-resources`,
|
||||
{ resource_ids: resourceIds },
|
||||
);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase B — Deliverables / progress
|
||||
// ---------------------------------------------------------------------
|
||||
async deliverables(planId: number): Promise<CoursePlanDeliverables> {
|
||||
return api.get(`/ai/course-plan/${planId}/deliverables`);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase C — Multimedia
|
||||
// ---------------------------------------------------------------------
|
||||
async listMaterialMedia(
|
||||
materialId: number,
|
||||
): Promise<{ items: CoursePlanMedia[]; count: number }> {
|
||||
return api.get(`/ai/course-plan/material/${materialId}/media`);
|
||||
},
|
||||
|
||||
async generateAudio(
|
||||
materialId: number,
|
||||
payload?: {
|
||||
voice?: string;
|
||||
language?: string;
|
||||
gender?: "male" | "female";
|
||||
provider?: "polly" | "elevenlabs";
|
||||
},
|
||||
): Promise<{ data: CoursePlanMedia }> {
|
||||
return api.post(
|
||||
`/ai/course-plan/material/${materialId}/media/audio`,
|
||||
payload ?? {},
|
||||
);
|
||||
},
|
||||
|
||||
async generateImage(
|
||||
materialId: number,
|
||||
payload?: {
|
||||
prompt?: string;
|
||||
size?: "1024x1024" | "1792x1024" | "1024x1792";
|
||||
style?: "natural" | "vivid";
|
||||
quality?: "standard" | "hd";
|
||||
},
|
||||
): Promise<{ data: CoursePlanMedia }> {
|
||||
return api.post(
|
||||
`/ai/course-plan/material/${materialId}/media/image`,
|
||||
payload ?? {},
|
||||
);
|
||||
},
|
||||
|
||||
async composeVideo(materialId: number): Promise<{ data: CoursePlanMedia }> {
|
||||
return api.post(
|
||||
`/ai/course-plan/material/${materialId}/media/video`,
|
||||
);
|
||||
},
|
||||
|
||||
async deleteMedia(mediaId: number): Promise<{ success: boolean }> {
|
||||
return api.delete(`/ai/course-plan/media/${mediaId}`);
|
||||
},
|
||||
|
||||
async generateWeekMedia(
|
||||
planId: number,
|
||||
weekNumber: number,
|
||||
payload?: { kinds?: Array<"audio" | "image" | "video"> },
|
||||
): Promise<{ items: Array<CoursePlanMedia | { error: string; material_id: number }>; count: number }> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/weeks/${weekNumber}/media`,
|
||||
payload ?? {},
|
||||
);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase D — Assignments
|
||||
// ---------------------------------------------------------------------
|
||||
async listAssignments(
|
||||
planId: number,
|
||||
): Promise<{ items: CoursePlanAssignment[]; count: number }> {
|
||||
return api.get(`/ai/course-plan/${planId}/assignments`);
|
||||
},
|
||||
|
||||
async createAssignment(
|
||||
planId: number,
|
||||
payload:
|
||||
| {
|
||||
mode: "batch";
|
||||
batch_id: number;
|
||||
due_date?: string | null;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
mode: "students";
|
||||
student_user_ids: number[];
|
||||
due_date?: string | null;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
mode: "entities";
|
||||
entity_ids: number[];
|
||||
due_date?: string | null;
|
||||
message?: string;
|
||||
},
|
||||
): Promise<{ data: CoursePlanAssignment }> {
|
||||
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
|
||||
},
|
||||
|
||||
async deleteAssignment(
|
||||
planId: number,
|
||||
assignmentId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
return api.delete(
|
||||
`/ai/course-plan/${planId}/assignments/${assignmentId}`,
|
||||
);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase E — Student-side
|
||||
// ---------------------------------------------------------------------
|
||||
async studentList(): Promise<{ items: CoursePlan[]; count: number }> {
|
||||
return api.get("/student/course-plans");
|
||||
},
|
||||
|
||||
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
||||
return api.get(`/student/course-plans/${planId}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,14 @@ import { api } from "@/lib/api-client";
|
||||
import { asPaginated, asRecordData } from "@/lib/odoo-api";
|
||||
import type { Entity, EntityRole, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
||||
|
||||
export interface EntityUser {
|
||||
id: number;
|
||||
name: string;
|
||||
login: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const entitiesService = {
|
||||
async list(params?: PaginationParams): Promise<PaginatedResponse<Entity>> {
|
||||
const raw = await api.get<unknown>("/entities", params as Record<string, string | number | boolean | undefined>);
|
||||
@@ -42,4 +50,20 @@ export const entitiesService = {
|
||||
async getPermissions(entityId: number): Promise<string[]> {
|
||||
return api.get<string[]>(`/permissions`, { entity_id: entityId });
|
||||
},
|
||||
|
||||
async listEntityUsers(entityId: number): Promise<EntityUser[]> {
|
||||
const raw = await api.get<unknown>(`/entities/${entityId}/users`);
|
||||
const out = asPaginated<EntityUser>(raw);
|
||||
return out.items;
|
||||
},
|
||||
|
||||
async updateEntityUsers(entityId: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
||||
return api.patch<ApiSuccessResponse>(`/entities/${entityId}/users`, { user_ids: userIds });
|
||||
},
|
||||
|
||||
async listPlatformUsers(params?: PaginationParams): Promise<EntityUser[]> {
|
||||
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
|
||||
const out = asPaginated<EntityUser>(raw);
|
||||
return out.items;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,12 +42,27 @@ export const resourcesService = {
|
||||
return api.post<ApiSuccessResponse>(`/resources/${id}/rate`, { rating });
|
||||
},
|
||||
|
||||
async download(id: number): Promise<Blob> {
|
||||
/**
|
||||
* Downloads the binary and returns the blob alongside the filename
|
||||
* the server suggested via ``Content-Disposition``. Callers should
|
||||
* prefer that filename over the human ``name`` on the record so the
|
||||
* extension is preserved (".pdf" / ".mp3" / ".png" etc.) — the
|
||||
* legacy code dropped the extension because the human name was just
|
||||
* "test".
|
||||
*/
|
||||
async download(id: number): Promise<{ blob: Blob; filename: string }> {
|
||||
const res = await fetch(`${API_BASE_URL}/resources/${id}/download`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
||||
return res.blob();
|
||||
const cd = res.headers.get("content-disposition") || "";
|
||||
let filename = "";
|
||||
// Match either ``filename*=UTF-8''…`` (RFC 5987) or ``filename="…"``
|
||||
const match =
|
||||
/filename\*\s*=\s*[^']*''([^;]+)/i.exec(cd) ||
|
||||
/filename\s*=\s*"?([^";]+)"?/i.exec(cd);
|
||||
if (match) filename = decodeURIComponent(match[1].trim());
|
||||
return { blob: await res.blob(), filename };
|
||||
},
|
||||
|
||||
// Tag management
|
||||
|
||||
@@ -61,8 +61,24 @@ export interface Resource {
|
||||
id: number;
|
||||
name: string;
|
||||
type?: string;
|
||||
resource_type: "pdf" | "video" | "link" | "document" | "interactive";
|
||||
resource_type:
|
||||
| "pdf"
|
||||
| "video"
|
||||
| "link"
|
||||
| "document"
|
||||
| "interactive"
|
||||
| "audio"
|
||||
| "image"
|
||||
| "article";
|
||||
url?: string;
|
||||
/** Authenticated REST URL for forced download (Content-Disposition: attachment). */
|
||||
download_url?: string;
|
||||
/** Authenticated REST URL for inline preview (Content-Disposition: inline). */
|
||||
preview_url?: string;
|
||||
/** Cached MIME type from upload — drives the right preview widget. */
|
||||
mimetype?: string;
|
||||
/** Filename as uploaded, including extension. */
|
||||
original_filename?: string;
|
||||
file_name?: string;
|
||||
subject_id?: number | null;
|
||||
subject_name?: string;
|
||||
|
||||
@@ -77,15 +77,177 @@ export interface CoursePlanMaterial {
|
||||
skill: string;
|
||||
material_type: CoursePlanMaterialType | string;
|
||||
title: string;
|
||||
is_static?: boolean;
|
||||
share_date?: string | null;
|
||||
summary: string;
|
||||
/** Loose shape: depends on material_type. */
|
||||
body: Record<string, unknown>;
|
||||
body_text: string;
|
||||
media?: CoursePlanMedia[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase A — Reference sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CoursePlanSourceKind = "file" | "url" | "text" | "resource";
|
||||
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
|
||||
|
||||
export interface CoursePlanSource {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
name: string;
|
||||
kind: CoursePlanSourceKind;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
url: string;
|
||||
has_inline_text: boolean;
|
||||
/** Set when the source is linked to an item from /admin/resources. */
|
||||
resource_id?: number | null;
|
||||
resource_name?: string;
|
||||
resource_type?: string;
|
||||
status: CoursePlanSourceStatus;
|
||||
error: string;
|
||||
chunks_count: number;
|
||||
extracted_chars: number;
|
||||
indexed_at: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase B — Deliverables preview / progress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DeliverableStatus = "planned" | "generated" | "ready";
|
||||
|
||||
export interface CoursePlanDeliverable {
|
||||
skill: string;
|
||||
material_type: string;
|
||||
material_id: number | null;
|
||||
title: string;
|
||||
status: DeliverableStatus;
|
||||
media: Array<{
|
||||
id: number;
|
||||
kind: "audio" | "image" | "video";
|
||||
status: string;
|
||||
provider: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CoursePlanDeliverablesWeek {
|
||||
week_number: number;
|
||||
date_label: string;
|
||||
unit: string;
|
||||
focus: string;
|
||||
items_total: number;
|
||||
deliverables: CoursePlanDeliverable[];
|
||||
}
|
||||
|
||||
export interface CoursePlanDeliverables {
|
||||
summary: {
|
||||
total: number;
|
||||
planned: number;
|
||||
generated: number;
|
||||
ready: number;
|
||||
percent_ready: number;
|
||||
media: {
|
||||
audio: number;
|
||||
image: number;
|
||||
video: number;
|
||||
audio_ready: number;
|
||||
image_ready: number;
|
||||
video_ready: number;
|
||||
};
|
||||
};
|
||||
weeks: CoursePlanDeliverablesWeek[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase C — Multimedia
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CoursePlanMediaKind = "audio" | "image" | "video";
|
||||
export type CoursePlanMediaStatus = "queued" | "generating" | "ready" | "failed";
|
||||
export type CoursePlanMediaProvider =
|
||||
| "polly"
|
||||
| "elevenlabs"
|
||||
| "openai_image"
|
||||
| "ffmpeg"
|
||||
| "elai"
|
||||
// Free fallbacks (Phase 24.1)
|
||||
| "pillow"
|
||||
| "unsplash"
|
||||
| "gtts"
|
||||
| "silent"
|
||||
| "static"
|
||||
| "mock"
|
||||
| "auto"
|
||||
| "manual";
|
||||
|
||||
export interface CoursePlanMedia {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
week_id: number | null;
|
||||
material_id: number | null;
|
||||
kind: CoursePlanMediaKind;
|
||||
provider: CoursePlanMediaProvider | string;
|
||||
title: string;
|
||||
voice: string;
|
||||
language: string;
|
||||
style: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
duration_seconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
attachment_id: number | null;
|
||||
/** REST URL with ``Content-Disposition: attachment`` — used by the
|
||||
* download buttons. The frontend appends ``?token=<jwt>`` so it works
|
||||
* from plain ``<a download>`` tags. */
|
||||
download_url: string;
|
||||
/** REST URL with ``Content-Disposition: inline`` — used as the ``src``
|
||||
* for ``<img>`` / ``<audio>`` / ``<video>`` tags. The frontend appends
|
||||
* ``?token=<jwt>`` because browsers can't attach custom headers to
|
||||
* these element fetches. */
|
||||
preview_url?: string;
|
||||
status: CoursePlanMediaStatus;
|
||||
error: string;
|
||||
cost_cents: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase D — Assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CoursePlanAssignmentMode = "batch" | "students" | "entities";
|
||||
export type CoursePlanAssignmentState = "active" | "archived";
|
||||
|
||||
export interface CoursePlanAssignment {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
plan_name: string;
|
||||
mode: CoursePlanAssignmentMode;
|
||||
batch_id: number | null;
|
||||
batch_name: string;
|
||||
student_user_ids: number[];
|
||||
student_user_names: string[];
|
||||
entity_ids?: number[];
|
||||
entity_names?: string[];
|
||||
student_count: number;
|
||||
assigned_by_id: number | null;
|
||||
assigned_by_name: string;
|
||||
due_date: string | null;
|
||||
message: string;
|
||||
state: CoursePlanAssignmentState;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface CoursePlan {
|
||||
id: number;
|
||||
name: string;
|
||||
entity_id?: number | null;
|
||||
entity_name?: string;
|
||||
course_id: number | null;
|
||||
course_name: string;
|
||||
cefr_level: string;
|
||||
@@ -101,13 +263,22 @@ export interface CoursePlan {
|
||||
resources: CoursePlanResource[];
|
||||
week_count: number;
|
||||
material_count: number;
|
||||
source_count?: number;
|
||||
media_count?: number;
|
||||
assignment_count?: number;
|
||||
created_at: string | null;
|
||||
weeks?: CoursePlanWeek[];
|
||||
materials?: CoursePlanMaterial[];
|
||||
sources?: CoursePlanSource[];
|
||||
media?: CoursePlanMedia[];
|
||||
assignments?: CoursePlanAssignment[];
|
||||
/** Present on the student-list endpoint payload. */
|
||||
assignment?: CoursePlanAssignment;
|
||||
}
|
||||
|
||||
export interface CoursePlanGenerateBrief {
|
||||
title: string;
|
||||
entity_id?: number;
|
||||
cefr_level?: string;
|
||||
total_weeks?: number;
|
||||
contact_hours_per_week?: number;
|
||||
|
||||
181
smoke_course_plan.py
Normal file
181
smoke_course_plan.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""End-to-end smoke test for the AI course-plan feature.
|
||||
|
||||
Run via: odoo-bin shell -c odoo.conf -d encoach_v2 < smoke_course_plan.py
|
||||
|
||||
Tests Phases A-E of the LangGraph-based course-plan pipeline:
|
||||
A. Sources / RAG (encoach.course.plan.source + SourceIndexer)
|
||||
B. Deliverables (services.deliverables.compute_deliverables)
|
||||
C. Multimedia (encoach.course.plan.media + MediaService)
|
||||
D. Assignments (encoach.course.plan.assignment)
|
||||
E. Student visibility (assignment.expand_user_ids)
|
||||
|
||||
External-API steps (DALL-E, Polly) tolerate missing credentials by
|
||||
checking ``status == 'failed'`` and reporting the provider error rather
|
||||
than failing the whole script.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def hr(title):
|
||||
print(f"\n{'='*70}\n{title}\n{'='*70}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f" FAIL {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print(f" PASS {msg}")
|
||||
|
||||
|
||||
hr("0. Locate fixtures")
|
||||
Plan = env['encoach.course.plan'].sudo()
|
||||
plan = Plan.search([], order='id desc', limit=1)
|
||||
if not plan:
|
||||
fail("No course plan in DB; seed one first")
|
||||
print(f" using plan #{plan.id}: {plan.name!r} (CEFR {plan.cefr_level})")
|
||||
|
||||
Users = env['res.users'].sudo()
|
||||
sarah = Users.search([('login', '=', 'sarah@encoach.test')], limit=1)
|
||||
if not sarah:
|
||||
fail("sarah@encoach.test not seeded")
|
||||
print(f" using student user #{sarah.id}: {sarah.name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Phase A
|
||||
hr("Phase A — Sources & RAG indexing")
|
||||
|
||||
Source = env['encoach.course.plan.source'].sudo()
|
||||
src = Source.create({
|
||||
'plan_id': plan.id,
|
||||
'name': 'Smoke test inline source',
|
||||
'kind': 'text',
|
||||
'inline_text': (
|
||||
'In Week 1 of the General English course we focus on present '
|
||||
'simple and present continuous. Reading texts target daily '
|
||||
'routines, family, and study habits at CEFR A2-B1 level. '
|
||||
'Listening scripts feature 3-minute monologues on student life '
|
||||
'with comprehension questions covering main idea and detail.'
|
||||
),
|
||||
'mime_type': 'text/plain',
|
||||
})
|
||||
ok(f"created source #{src.id}")
|
||||
|
||||
from odoo.addons.encoach_ai_course.services.source_indexer import (
|
||||
SourceIndexer,
|
||||
)
|
||||
result = SourceIndexer(env).index(src)
|
||||
print(f" index result: {result}")
|
||||
src.invalidate_recordset()
|
||||
if src.status == 'indexed' and src.chunks_count > 0:
|
||||
ok(f"indexed: {src.chunks_count} chunks, {src.extracted_chars} chars")
|
||||
elif src.status == 'failed':
|
||||
print(f" WARN indexing failed (likely no embedding API key configured): "
|
||||
f"{src.error}")
|
||||
else:
|
||||
print(f" WARN unexpected status: {src.status}")
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Phase B
|
||||
hr("Phase B — Deliverables computation")
|
||||
from odoo.addons.encoach_ai_course.services.deliverables import (
|
||||
compute_deliverables,
|
||||
)
|
||||
deliv = compute_deliverables(plan)
|
||||
summary = deliv['summary']
|
||||
print(f" summary: total={summary['total']} planned={summary['planned']} "
|
||||
f"generated={summary['generated']} ready={summary['ready']} "
|
||||
f"%={summary['percent_ready']}")
|
||||
print(f" media (current/ready): "
|
||||
f"audio {summary['media']['audio']}/{summary['media']['audio_ready']} "
|
||||
f"image {summary['media']['image']}/{summary['media']['image_ready']} "
|
||||
f"video {summary['media']['video']}/{summary['media']['video_ready']}")
|
||||
ok(f"compute_deliverables returned {len(deliv['weeks'])} weeks")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Phase C
|
||||
hr("Phase C — Multimedia generation (audio + image)")
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
mat_listening = plan.material_ids.filtered(
|
||||
lambda m: m.material_type == 'listening_script'
|
||||
)[:1]
|
||||
mat_reading = plan.material_ids.filtered(
|
||||
lambda m: m.material_type == 'reading_text'
|
||||
)[:1]
|
||||
|
||||
svc = MediaService(env)
|
||||
if mat_listening:
|
||||
audio_media = svc.synthesize_audio(mat_listening, language='en-GB')
|
||||
print(f" audio media #{audio_media.id} status={audio_media.status} "
|
||||
f"err={(audio_media.error or '')[:120]!r}")
|
||||
if audio_media.status == 'ready':
|
||||
ok(f"audio bytes={audio_media.size_bytes}")
|
||||
else:
|
||||
print(f" WARN audio not ready (provider may need creds)")
|
||||
else:
|
||||
print(" SKIP no listening_script material on plan")
|
||||
|
||||
if mat_reading:
|
||||
image_media = svc.generate_image(mat_reading, size='1024x1024')
|
||||
print(f" image media #{image_media.id} status={image_media.status} "
|
||||
f"err={(image_media.error or '')[:120]!r}")
|
||||
if image_media.status == 'ready':
|
||||
ok(f"image bytes={image_media.size_bytes}")
|
||||
else:
|
||||
print(f" WARN image not ready (provider may need creds)")
|
||||
else:
|
||||
print(" SKIP no reading_text material on plan")
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Phase D
|
||||
hr("Phase D — Assignment creation")
|
||||
Assignment = env['encoach.course.plan.assignment'].sudo()
|
||||
existing = Assignment.search([
|
||||
('plan_id', '=', plan.id),
|
||||
('mode', '=', 'students'),
|
||||
('state', '=', 'active'),
|
||||
])
|
||||
existing.unlink()
|
||||
asn = Assignment.create({
|
||||
'plan_id': plan.id,
|
||||
'mode': 'students',
|
||||
'student_user_ids': [(6, 0, [sarah.id])],
|
||||
'message': 'Smoke test assignment',
|
||||
'state': 'active',
|
||||
})
|
||||
ok(f"created assignment #{asn.id} for {len(asn.student_user_ids)} student(s)")
|
||||
print(f" to_api_dict: {json.dumps(asn.to_api_dict(), default=str)[:200]} ...")
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Phase E
|
||||
hr("Phase E — Student visibility")
|
||||
expanded = asn.expand_user_ids()
|
||||
print(f" expand_user_ids -> {expanded}")
|
||||
if sarah.id in expanded:
|
||||
ok("sarah@encoach.test is included in assignment expansion")
|
||||
else:
|
||||
fail("sarah not visible in assignment.expand_user_ids()")
|
||||
|
||||
assignments = Assignment.search([
|
||||
('state', '=', 'active'),
|
||||
('student_user_ids', 'in', [sarah.id]),
|
||||
])
|
||||
visible_plans = {a.plan_id.id for a in assignments
|
||||
if sarah.id in a.expand_user_ids()}
|
||||
print(f" Sarah will see plans: {sorted(visible_plans)}")
|
||||
if plan.id in visible_plans:
|
||||
ok("Plan appears in student-side listing query")
|
||||
else:
|
||||
fail("Plan NOT in student-side listing")
|
||||
|
||||
|
||||
hr("DONE — Course-plan smoke test passed (provider warnings are OK)")
|
||||
print("Test source / assignment created. Review in admin UI on plan #%d." % plan.id)
|
||||
183
smoke_provider_fallback.py
Normal file
183
smoke_provider_fallback.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Smoke test for the AI provider fallback chain (Phase 24.1).
|
||||
|
||||
Exercises the new code paths added in this session:
|
||||
|
||||
1. provider_router.classify_provider_error — error taxonomy
|
||||
2. provider_router.resolve_chain — fallback ordering
|
||||
3. free_image.render_placeholder — offline Pillow card
|
||||
4. free_tts.synthesize_silent — offline silent MP3 stub
|
||||
5. MediaService.generate_image('pillow') — full fallback round-trip
|
||||
6. MediaService.synthesize_audio('silent') — silent fallback persists
|
||||
7. AI settings ir.config_parameter wiring — get/set via params
|
||||
|
||||
Run via:
|
||||
odoo-bin shell -c odoo.conf -d encoach_v2 < smoke_provider_fallback.py
|
||||
|
||||
External APIs are NEVER called by this script — the whole point is to
|
||||
prove the platform stays up when paid keys are missing or exhausted.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def hr(title):
|
||||
print(f"\n{'='*72}\n{title}\n{'='*72}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f" FAIL {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print(f" PASS {msg}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 1
|
||||
hr("1. provider_router.classify_provider_error")
|
||||
from odoo.addons.encoach_ai.services.provider_router import (
|
||||
classify_provider_error,
|
||||
resolve_chain,
|
||||
CAPABILITIES,
|
||||
get_active_provider,
|
||||
)
|
||||
|
||||
|
||||
class _OpenAIInsufficientQuota(Exception):
|
||||
pass
|
||||
|
||||
|
||||
cases = [
|
||||
(Exception('insufficient_quota: You exceeded your current quota'), 'quota'),
|
||||
(Exception('Error code: 429 - {"error": {"code": "rate_limit_exceeded"}}'), 'quota'),
|
||||
(Exception('HTTP 402 Payment Required'), 'quota'),
|
||||
(Exception('Invalid API key provided'), 'auth'),
|
||||
(Exception('AccessDenied: 403'), 'auth'),
|
||||
(Exception('Connection timeout after 30s'), 'network'),
|
||||
(Exception('Something completely unexpected'), 'other'),
|
||||
]
|
||||
for exc, expected in cases:
|
||||
got = classify_provider_error(exc)
|
||||
if got != expected:
|
||||
fail(f"expected {expected!r} got {got!r} for {exc}")
|
||||
ok(f"all {len(cases)} error-classification cases match")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 2
|
||||
hr("2. provider_router.resolve_chain")
|
||||
print(f" capabilities: {sorted(CAPABILITIES.keys())}")
|
||||
chain = resolve_chain(env, 'image')
|
||||
print(f" image chain (auto): {chain}")
|
||||
if 'pillow' not in chain:
|
||||
fail("pillow free fallback missing from image chain")
|
||||
ok("image chain contains free fallback")
|
||||
|
||||
chain_audio = resolve_chain(env, 'audio')
|
||||
print(f" audio chain (auto): {chain_audio}")
|
||||
if 'silent' not in chain_audio:
|
||||
fail("silent fallback missing from audio chain")
|
||||
ok("audio chain ends in silent stub")
|
||||
|
||||
chain_forced = resolve_chain(env, 'image', requested='pillow')
|
||||
print(f" image chain when requested=pillow: {chain_forced}")
|
||||
if chain_forced[0] != 'pillow':
|
||||
fail("explicit request was not pinned to front of chain")
|
||||
ok("explicit provider goes to front of chain")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 3
|
||||
hr("3. free_image.render_placeholder")
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.free_image import render_placeholder
|
||||
png = render_placeholder(
|
||||
'Week 1 — Daily Routines',
|
||||
subtitle='CEFR A2 · Reading',
|
||||
size='512x512',
|
||||
seed=42,
|
||||
)
|
||||
if not png or len(png) < 1000:
|
||||
fail(f"rendered PNG too small: {len(png)} bytes")
|
||||
if not png.startswith(b'\x89PNG'):
|
||||
fail("rendered bytes are not a valid PNG header")
|
||||
ok(f"placeholder rendered ({len(png):,} bytes, valid PNG)")
|
||||
except RuntimeError as exc:
|
||||
print(f" SKIP Pillow not installed: {exc}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 4
|
||||
hr("4. free_tts.synthesize_silent")
|
||||
from odoo.addons.encoach_ai.services.free_tts import synthesize_silent
|
||||
res = synthesize_silent(duration_seconds=2)
|
||||
if not res.get('audio') or len(res['audio']) < 100:
|
||||
fail(f"silent stub too small: {len(res.get('audio') or b'')}")
|
||||
if res.get('content_type') not in ('audio/mpeg', 'audio/wav'):
|
||||
fail(f"expected audio/mpeg|wav, got {res.get('content_type')}")
|
||||
ok(f"silent stub: {len(res['audio'])} bytes, voice={res['voice']}, "
|
||||
f"content_type={res['content_type']}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 5
|
||||
hr("5. MediaService.generate_image (forced pillow provider)")
|
||||
Plan = env['encoach.course.plan'].sudo()
|
||||
plan = Plan.search([], order='id desc', limit=1)
|
||||
if not plan:
|
||||
fail("No course plan in DB; seed one first")
|
||||
material = plan.material_ids.filtered(
|
||||
lambda m: m.material_type == 'reading_text'
|
||||
)[:1]
|
||||
if not material:
|
||||
fail("Plan has no reading_text material")
|
||||
print(f" using plan #{plan.id}, material #{material.id}: {material.title!r}")
|
||||
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
svc = MediaService(env)
|
||||
image_media = svc.generate_image(material, provider='pillow')
|
||||
print(f" image media #{image_media.id} status={image_media.status} "
|
||||
f"provider={image_media.provider} size={image_media.size_bytes}")
|
||||
if image_media.status != 'ready':
|
||||
fail(f"pillow image generation failed: {image_media.error}")
|
||||
if image_media.provider != 'pillow':
|
||||
fail(f"expected provider=pillow, got {image_media.provider}")
|
||||
ok(f"pillow fallback produced ready image of {image_media.size_bytes:,} bytes")
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 6
|
||||
hr("6. MediaService.synthesize_audio (forced silent provider)")
|
||||
mat_listening = plan.material_ids.filtered(
|
||||
lambda m: m.material_type == 'listening_script'
|
||||
)[:1]
|
||||
if not mat_listening:
|
||||
print(" SKIP no listening_script material on plan")
|
||||
else:
|
||||
audio_media = svc.synthesize_audio(mat_listening, provider='silent')
|
||||
print(f" audio media #{audio_media.id} status={audio_media.status} "
|
||||
f"provider={audio_media.provider}")
|
||||
if audio_media.status != 'ready':
|
||||
fail(f"silent audio generation failed: {audio_media.error}")
|
||||
if audio_media.provider != 'silent':
|
||||
fail(f"expected provider=silent, got {audio_media.provider}")
|
||||
ok(f"silent fallback produced ready audio of {audio_media.size_bytes:,} bytes")
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 7
|
||||
hr("7. ir.config_parameter wiring (provider switch)")
|
||||
Param = env['ir.config_parameter'].sudo()
|
||||
original = Param.get_param('encoach.ai.image_provider', '')
|
||||
print(f" current encoach.ai.image_provider = {original!r}")
|
||||
Param.set_param('encoach.ai.image_provider', 'pillow')
|
||||
got_after = get_active_provider(env, 'image')
|
||||
print(f" after set: {got_after!r}")
|
||||
if got_after != 'pillow':
|
||||
fail(f"provider switch did not persist: got {got_after!r}")
|
||||
# Restore so we don't leave the DB in test state
|
||||
Param.set_param('encoach.ai.image_provider', original or 'auto')
|
||||
ok("provider switch read back correctly (no caching)")
|
||||
|
||||
|
||||
hr("DONE — All 7 fallback checks passed")
|
||||
print("Free providers verified end-to-end. Paid providers will continue")
|
||||
print("to work whenever credentials are configured; quota errors silently")
|
||||
print("degrade to the same free chain we just exercised.")
|
||||
Reference in New Issue
Block a user