feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform. Made-with: Cursor
This commit is contained in:
@@ -7,12 +7,14 @@ decorator and returns JSON.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required,
|
||||
validate_token,
|
||||
_json_response,
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
@@ -42,7 +44,65 @@ def _request_language():
|
||||
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
|
||||
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for current JWT user."""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
return entity_ids, is_super
|
||||
|
||||
|
||||
def _default_entity_id_from_scope():
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if entity_ids:
|
||||
return entity_ids[0]
|
||||
if is_super:
|
||||
return False
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
|
||||
|
||||
def _ensure_entity_access(entity_id):
|
||||
if not entity_id:
|
||||
raise PermissionError('entity_id is required')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return int(entity_id)
|
||||
if not entity_ids:
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
if int(entity_id) not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
return int(entity_id)
|
||||
|
||||
|
||||
class CoursePlanController(http.Controller):
|
||||
def _plan_domain(self, extra=None):
|
||||
domain = list(extra or [])
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not entity_ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [('entity_id', 'in', entity_ids)]
|
||||
|
||||
def _assert_plan_access(self, plan):
|
||||
if not plan or not plan.exists():
|
||||
raise ValueError('Plan not found')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return
|
||||
if not plan.entity_id or plan.entity_id.id not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
|
||||
def _get_plan_scoped(self, plan_id):
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
self._assert_plan_access(plan)
|
||||
return plan
|
||||
|
||||
def _assert_material_access(self, material):
|
||||
if not material or not material.exists():
|
||||
raise ValueError('Material not found')
|
||||
self._assert_plan_access(material.plan_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan
|
||||
# ------------------------------------------------------------------
|
||||
@@ -54,15 +114,21 @@ class CoursePlanController(http.Controller):
|
||||
body = _get_json_body()
|
||||
if not (body.get('title') or '').strip():
|
||||
return _error_response('title is required', 400)
|
||||
if body.get('entity_id'):
|
||||
entity_id = _ensure_entity_access(int(body['entity_id']))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
plan = pipeline.generate_plan(body)
|
||||
if entity_id:
|
||||
plan.sudo().write({'entity_id': entity_id})
|
||||
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan
|
||||
@@ -73,10 +139,12 @@ class CoursePlanController(http.Controller):
|
||||
def list_plans(self, **kw):
|
||||
try:
|
||||
params = request.httprequest.args
|
||||
domain = []
|
||||
domain = self._plan_domain([])
|
||||
search = (params.get('search') or '').strip()
|
||||
if search:
|
||||
domain.append(('name', 'ilike', search))
|
||||
if params.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id')))))
|
||||
|
||||
Plan = request.env['encoach.course.plan'].sudo()
|
||||
offset, limit, page = _paginate({
|
||||
@@ -94,7 +162,7 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>
|
||||
@@ -104,15 +172,15 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def get_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.get failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/ai/course-plan/<id>
|
||||
@@ -122,14 +190,14 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def delete_plan(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
plan.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -139,6 +207,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def generate_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
@@ -151,7 +220,7 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -161,6 +230,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
week = request.env['encoach.course.plan.week'].sudo().search([
|
||||
('plan_id', '=', int(plan_id)),
|
||||
('week_number', '=', int(week_number)),
|
||||
@@ -173,7 +243,7 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE A — Reference sources (RAG grounding)
|
||||
@@ -184,25 +254,23 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_sources(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [s.to_api_dict() for s in plan.source_ids],
|
||||
'count': len(plan.source_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_sources failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_source(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
ct = request.httprequest.content_type or ''
|
||||
files = request.httprequest.files
|
||||
@@ -250,37 +318,110 @@ class CoursePlanController(http.Controller):
|
||||
|
||||
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<plan_id>/sources/from-resources
|
||||
# ------------------------------------------------------------------
|
||||
# Attach one or more existing library resources (``encoach.resource``,
|
||||
# the items shown under /admin/resources) as RAG sources for a plan.
|
||||
#
|
||||
# Body shape: ``{ "resource_ids": [<int>, ...] }``. We dedupe against
|
||||
# already-linked resources so re-clicking "Attach" is a no-op rather
|
||||
# than producing duplicate index entries. Each new row auto-indexes
|
||||
# via the ``encoach.course.plan.source`` create() hook.
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/from-resources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def attach_library_resources(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
raw_ids = body.get('resource_ids') or body.get('ids') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return _error_response('resource_ids must be a list', 400)
|
||||
try:
|
||||
resource_ids = [int(x) for x in raw_ids if x is not None]
|
||||
except (TypeError, ValueError):
|
||||
return _error_response('resource_ids must contain integers', 400)
|
||||
if not resource_ids:
|
||||
return _error_response('No resource ids provided', 400)
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
Source = request.env['encoach.course.plan.source'].sudo()
|
||||
|
||||
already_linked = set(
|
||||
plan.source_ids.filtered('resource_id').mapped('resource_id.id')
|
||||
)
|
||||
attached, skipped, missing = [], [], []
|
||||
for rid in resource_ids:
|
||||
if rid in already_linked:
|
||||
skipped.append(rid)
|
||||
continue
|
||||
res = Resource.browse(rid)
|
||||
if not res.exists():
|
||||
missing.append(rid)
|
||||
continue
|
||||
rec = Source.create({
|
||||
'plan_id': plan.id,
|
||||
'kind': 'resource',
|
||||
'resource_id': res.id,
|
||||
'name': res.name or f'Resource #{res.id}',
|
||||
'file_name': res.name or '',
|
||||
'mime_type': '',
|
||||
})
|
||||
attached.append(rec.to_api_dict())
|
||||
|
||||
return _json_response({
|
||||
'attached': attached,
|
||||
'skipped_existing': skipped,
|
||||
'missing': missing,
|
||||
'count': len(attached),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.attach_library_resources failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reindex_source(self, plan_id, source_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||
return _error_response('Source not found', 404)
|
||||
rec.action_index()
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.reindex_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_source(self, plan_id, source_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
|
||||
if not rec.exists() or rec.plan_id.id != int(plan_id):
|
||||
return _error_response('Source not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE B — Deliverables preview / progress
|
||||
@@ -291,13 +432,13 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def get_deliverables(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response(compute_deliverables(plan))
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.deliverables failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE C — Multimedia generation per material
|
||||
@@ -307,8 +448,48 @@ class CoursePlanController(http.Controller):
|
||||
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
|
||||
if not rec.exists():
|
||||
return None
|
||||
self._assert_material_access(rec)
|
||||
return rec
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>',
|
||||
type='http', auth='none', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def update_material(self, material_id, **kw):
|
||||
"""Edit generated material metadata/content without regenerating."""
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
vals = {}
|
||||
if 'title' in body:
|
||||
new_title = (body.get('title') or '').strip()
|
||||
if not new_title:
|
||||
return _error_response('title cannot be empty', 400)
|
||||
vals['title'] = new_title
|
||||
if 'summary' in body:
|
||||
vals['summary'] = (body.get('summary') or '').strip()
|
||||
if 'is_static' in body:
|
||||
vals['is_static'] = bool(body.get('is_static'))
|
||||
if 'share_date' in body:
|
||||
vals['share_date'] = body.get('share_date') or False
|
||||
if 'body' in body:
|
||||
vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False)
|
||||
if 'body_text' in body:
|
||||
body_text = (body.get('body_text') or '').strip()
|
||||
vals['body_text'] = body_text
|
||||
if 'body' not in body and body_text:
|
||||
# Keep non-technical editing simple: if only plain text is
|
||||
# provided, mirror it into a minimal JSON structure.
|
||||
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
|
||||
if vals:
|
||||
material.sudo().write(vals)
|
||||
return _json_response({'data': material.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.update_material failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -324,12 +505,13 @@ class CoursePlanController(http.Controller):
|
||||
voice=body.get('voice'),
|
||||
language=body.get('language') or 'en-GB',
|
||||
gender=body.get('gender') or 'female',
|
||||
provider=body.get('provider') or 'polly',
|
||||
provider=body.get('provider') or 'auto',
|
||||
)
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_audio failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -351,7 +533,8 @@ class CoursePlanController(http.Controller):
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_image failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -366,7 +549,8 @@ class CoursePlanController(http.Controller):
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_video failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@@ -382,7 +566,63 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_material_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/media/<id>/raw
|
||||
# ------------------------------------------------------------------
|
||||
# Streams the binary backing a media row. Used as the ``src`` for
|
||||
# ``<img>`` / ``<audio>`` / ``<video>`` tags AND as the target of
|
||||
# download links — the only difference is the ``?download=1`` flag.
|
||||
#
|
||||
# Accepts the JWT via the standard ``Authorization: Bearer …`` header
|
||||
# OR via ``?token=<jwt>`` / ``?access_token=<jwt>`` so plain ``<img>``
|
||||
# tags can render the asset without us blob-converting every fetch.
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>/raw',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
def stream_media(self, media_id, **kw):
|
||||
try:
|
||||
user = validate_token(allow_query_param=True)
|
||||
if not user:
|
||||
return _error_response('Authentication required', 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists() or not rec.attachment_id:
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
|
||||
attachment = rec.attachment_id
|
||||
payload = attachment.raw or b''
|
||||
if not payload and attachment.datas:
|
||||
# Older rows store the binary base64-encoded under ``datas``.
|
||||
payload = base64.b64decode(attachment.datas)
|
||||
if not payload:
|
||||
return _error_response('Media payload missing', 410)
|
||||
|
||||
mimetype = attachment.mimetype or rec.mime_type or 'application/octet-stream'
|
||||
filename = attachment.name or f'media-{rec.id}'
|
||||
disposition = (
|
||||
f'attachment; filename="{filename}"'
|
||||
if request.httprequest.args.get('download')
|
||||
else f'inline; filename="{filename}"'
|
||||
)
|
||||
headers = [
|
||||
('Content-Type', mimetype),
|
||||
('Content-Length', str(len(payload))),
|
||||
('Content-Disposition', disposition),
|
||||
# Aggressive caching is safe — the URL is keyed by the row id
|
||||
# and the binary is immutable once generated. Setting a 1h
|
||||
# max-age avoids re-streaming the same WAV/PNG every time the
|
||||
# admin re-opens the media drawer.
|
||||
('Cache-Control', 'private, max-age=3600'),
|
||||
]
|
||||
return request.make_response(payload, headers=headers)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.stream_media failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@@ -392,13 +632,15 @@ class CoursePlanController(http.Controller):
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists():
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
if rec.attachment_id:
|
||||
rec.attachment_id.unlink()
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -411,9 +653,7 @@ class CoursePlanController(http.Controller):
|
||||
depends on ffmpeg + the audio + image steps and is slower.
|
||||
"""
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
week = plan.week_ids.filtered(
|
||||
lambda w: w.week_number == int(week_number),
|
||||
)
|
||||
@@ -443,7 +683,8 @@ class CoursePlanController(http.Controller):
|
||||
return _json_response({'items': results, 'count': len(results)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_week_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE D — Plan assignments
|
||||
@@ -454,25 +695,23 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_assignments(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [a.to_api_dict() for a in plan.assignment_ids],
|
||||
'count': len(plan.assignment_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_assignments failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_assignment(self, plan_id, **kw):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
body = _get_json_body() or {}
|
||||
mode = (body.get('mode') or 'batch').strip()
|
||||
vals = {
|
||||
@@ -485,25 +724,40 @@ class CoursePlanController(http.Controller):
|
||||
if mode == 'batch':
|
||||
if not body.get('batch_id'):
|
||||
return _error_response('batch_id is required', 400)
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
batch_id = int(body['batch_id'])
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
if plan.entity_id and batch.entity_id and plan.entity_id.id != batch.entity_id.id:
|
||||
return _error_response('Batch entity does not match plan entity', 400)
|
||||
vals['batch_id'] = batch_id
|
||||
elif mode == 'students':
|
||||
ids = body.get('student_user_ids') or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return _error_response('student_user_ids is required', 400)
|
||||
vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])]
|
||||
elif mode == 'entities':
|
||||
ids = body.get('entity_ids') or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return _error_response('entity_ids is required', 400)
|
||||
checked = [_ensure_entity_access(int(i)) for i in ids]
|
||||
vals['entity_ids'] = [(6, 0, checked)]
|
||||
else:
|
||||
return _error_response('Invalid mode', 400)
|
||||
rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_assignment(self, plan_id, assignment_id, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
|
||||
int(assignment_id),
|
||||
)
|
||||
@@ -511,9 +765,11 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response('Assignment not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE E — Student-side endpoints
|
||||
@@ -535,12 +791,7 @@ class CoursePlanController(http.Controller):
|
||||
try:
|
||||
user = request.env.user
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
assignments = Assignment.search([
|
||||
('state', '=', 'active'),
|
||||
'|',
|
||||
('student_user_ids', 'in', [user.id]),
|
||||
'&', ('mode', '=', 'batch'), ('batch_id', '!=', False),
|
||||
])
|
||||
assignments = Assignment.search([('state', '=', 'active')])
|
||||
visible = []
|
||||
for a in assignments:
|
||||
if a.mode == 'students' and user.id in a.student_user_ids.ids:
|
||||
@@ -548,6 +799,9 @@ class CoursePlanController(http.Controller):
|
||||
continue
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
continue
|
||||
if a.mode == 'entities' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
@@ -582,6 +836,9 @@ class CoursePlanController(http.Controller):
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
allowed = True
|
||||
break
|
||||
if a.mode == 'entities' and user.id in a.expand_user_ids():
|
||||
allowed = True
|
||||
break
|
||||
if not allowed:
|
||||
return _error_response('Plan not assigned to you', 403)
|
||||
return _json_response({
|
||||
|
||||
Reference in New Issue
Block a user