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:
@@ -54,6 +54,13 @@ class CoursePlan(models.Model):
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
@@ -142,6 +149,15 @@ class CoursePlan(models.Model):
|
||||
rec.media_count = len(rec.media_ids)
|
||||
rec.assignment_count = len(rec.assignment_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
user = self.env.user.sudo()
|
||||
default_entity = user.entity_ids[:1].id if hasattr(user, 'entity_ids') else False
|
||||
for vals in vals_list:
|
||||
if vals.get('entity_id') is None and default_entity:
|
||||
vals['entity_id'] = default_entity
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation helpers — used by the REST controller so payload
|
||||
# shape stays in a single, obvious place.
|
||||
@@ -161,6 +177,8 @@ class CoursePlan(models.Model):
|
||||
data = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'entity_id': self.entity_id.id if self.entity_id else None,
|
||||
'entity_name': self.entity_id.name if self.entity_id else '',
|
||||
'course_id': self.course_id.id if self.course_id else None,
|
||||
'course_name': self.course_id.name if self.course_id else '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
@@ -264,6 +282,13 @@ class CoursePlanMaterial(models.Model):
|
||||
MATERIAL_TYPE_SELECTION, required=True, default='other',
|
||||
)
|
||||
title = fields.Char(required=True)
|
||||
is_static = fields.Boolean(
|
||||
default=False,
|
||||
help='When enabled, regenerate-week keeps this material untouched.',
|
||||
)
|
||||
share_date = fields.Date(
|
||||
help='Optional date when the material becomes visible/shared.',
|
||||
)
|
||||
summary = fields.Text(
|
||||
help='Short blurb — purpose / learning outcomes targeted / how to use.',
|
||||
)
|
||||
@@ -300,6 +325,8 @@ class CoursePlanMaterial(models.Model):
|
||||
'skill': self.skill or '',
|
||||
'material_type': self.material_type or 'other',
|
||||
'title': self.title or '',
|
||||
'is_static': bool(self.is_static),
|
||||
'share_date': self.share_date.isoformat() if self.share_date else None,
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
|
||||
@@ -22,6 +22,7 @@ _logger = logging.getLogger(__name__)
|
||||
ASSIGNMENT_MODE_SELECTION = [
|
||||
('batch', 'Class / Batch'),
|
||||
('students', 'Specific students'),
|
||||
('entities', 'Entities'),
|
||||
]
|
||||
|
||||
ASSIGNMENT_STATE_SELECTION = [
|
||||
@@ -47,6 +48,10 @@ class CoursePlanAssignment(models.Model):
|
||||
'res.users', 'course_plan_assignment_student_rel',
|
||||
'assignment_id', 'user_id', string='Specific students',
|
||||
)
|
||||
entity_ids = fields.Many2many(
|
||||
'encoach.entity', 'course_plan_assignment_entity_rel',
|
||||
'assignment_id', 'entity_id', string='Entities',
|
||||
)
|
||||
|
||||
assigned_by_id = fields.Many2one(
|
||||
'res.users', default=lambda self: self.env.user, string='Assigned by',
|
||||
@@ -57,7 +62,7 @@ class CoursePlanAssignment(models.Model):
|
||||
|
||||
student_count = fields.Integer(compute='_compute_student_count', store=False)
|
||||
|
||||
@api.depends('mode', 'batch_id', 'student_user_ids')
|
||||
@api.depends('mode', 'batch_id', 'student_user_ids', 'entity_ids')
|
||||
def _compute_student_count(self):
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
Batch = self.env['op.batch'].sudo()
|
||||
@@ -65,6 +70,14 @@ class CoursePlanAssignment(models.Model):
|
||||
if rec.mode == 'students':
|
||||
rec.student_count = len(rec.student_user_ids)
|
||||
continue
|
||||
if rec.mode == 'entities':
|
||||
user_ids = set()
|
||||
for entity in rec.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.add(user.id)
|
||||
rec.student_count = len(user_ids)
|
||||
continue
|
||||
if not rec.batch_id:
|
||||
rec.student_count = 0
|
||||
continue
|
||||
@@ -85,6 +98,13 @@ class CoursePlanAssignment(models.Model):
|
||||
self.ensure_one()
|
||||
if self.mode == 'students':
|
||||
return self.student_user_ids.ids
|
||||
if self.mode == 'entities':
|
||||
user_ids = []
|
||||
for entity in self.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.append(user.id)
|
||||
return list(set(user_ids))
|
||||
if self.mode == 'batch' and self.batch_id:
|
||||
try:
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
@@ -118,6 +138,8 @@ class CoursePlanAssignment(models.Model):
|
||||
'batch_name': self.batch_id.name if self.batch_id else '',
|
||||
'student_user_ids': self.student_user_ids.ids,
|
||||
'student_user_names': [u.name for u in self.student_user_ids],
|
||||
'entity_ids': self.entity_ids.ids,
|
||||
'entity_names': [e.name for e in self.entity_ids],
|
||||
'student_count': self.student_count or 0,
|
||||
'assigned_by_id': self.assigned_by_id.id if self.assigned_by_id else None,
|
||||
'assigned_by_name': self.assigned_by_id.name if self.assigned_by_id else '',
|
||||
|
||||
@@ -26,12 +26,25 @@ MEDIA_KIND_SELECTION = [
|
||||
]
|
||||
|
||||
MEDIA_PROVIDER_SELECTION = [
|
||||
# ── Paid providers ──
|
||||
('polly', 'AWS Polly'),
|
||||
('elevenlabs', 'ElevenLabs'),
|
||||
('openai_image', 'OpenAI (DALL-E)'),
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('elai', 'Elai.io'),
|
||||
# ── Free fallbacks (Phase 24.1) ──
|
||||
('pillow', 'Pillow placeholder (offline)'),
|
||||
('unsplash', 'Unsplash Source (free)'),
|
||||
('gtts', 'gTTS (free TTS)'),
|
||||
('silent', 'Silent stub'),
|
||||
('static', 'Static image as video'),
|
||||
('mock', 'Mock'),
|
||||
# ── Composers / manual ──
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('manual', 'Manual upload'),
|
||||
# Sentinel value used while the chain is still resolving — written
|
||||
# transiently by MediaService.create() and overwritten with the
|
||||
# successful provider name once a fallback step succeeds.
|
||||
('auto', 'Auto (fallback chain)'),
|
||||
]
|
||||
|
||||
MEDIA_STATUS_SELECTION = [
|
||||
@@ -76,8 +89,16 @@ class CoursePlanMedia(models.Model):
|
||||
width = fields.Integer()
|
||||
height = fields.Integer()
|
||||
download_url = fields.Char(
|
||||
compute='_compute_download_url', store=False,
|
||||
help='Web-accessible URL served by Odoo (/web/content/<id>).',
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary as an attachment '
|
||||
'(``Content-Disposition: attachment``). Frontend appends '
|
||||
'``?token=<jwt>`` for download buttons.',
|
||||
)
|
||||
preview_url = fields.Char(
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary inline so it can '
|
||||
'be used as the ``src`` for ``<img>`` / ``<audio>`` / ``<video>`` '
|
||||
'elements. Frontend appends ``?token=<jwt>``.',
|
||||
)
|
||||
|
||||
status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued')
|
||||
@@ -89,13 +110,24 @@ class CoursePlanMedia(models.Model):
|
||||
)
|
||||
|
||||
@api.depends('attachment_id')
|
||||
def _compute_download_url(self):
|
||||
def _compute_media_urls(self):
|
||||
# Both URLs hit the same JWT-protected streaming endpoint exposed by
|
||||
# ``encoach_ai_course.controllers.course_plan.CoursePlanController.
|
||||
# stream_media``. The endpoint accepts the JWT either as a Bearer
|
||||
# header (REST clients) or as a ``?token=`` query param so plain
|
||||
# ``<img>`` / ``<audio>`` / ``<video>`` tags can render the asset
|
||||
# without us proxying every request through fetch + blob URLs.
|
||||
# ``download=1`` only changes the Content-Disposition: attachment
|
||||
# header so the same route serves both inline previews and explicit
|
||||
# downloads.
|
||||
for rec in self:
|
||||
rec.download_url = (
|
||||
f'/web/content/{rec.attachment_id.id}?download=true&filename='
|
||||
f'{rec.attachment_id.name or "media"}'
|
||||
if rec.attachment_id else ''
|
||||
)
|
||||
if not rec.id:
|
||||
rec.download_url = ''
|
||||
rec.preview_url = ''
|
||||
continue
|
||||
base = f'/api/ai/course-plan/media/{rec.id}/raw'
|
||||
rec.preview_url = base
|
||||
rec.download_url = f'{base}?download=1'
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
@@ -117,6 +149,7 @@ class CoursePlanMedia(models.Model):
|
||||
'height': self.height or 0,
|
||||
'attachment_id': self.attachment_id.id if self.attachment_id else None,
|
||||
'download_url': self.download_url,
|
||||
'preview_url': self.preview_url,
|
||||
'status': self.status or 'queued',
|
||||
'error': self.error or '',
|
||||
'cost_cents': self.cost_cents or 0,
|
||||
|
||||
@@ -25,6 +25,13 @@ SOURCE_KIND_SELECTION = [
|
||||
('file', 'File'),
|
||||
('url', 'URL'),
|
||||
('text', 'Inline text'),
|
||||
# ``resource`` is a soft-link to the central ``encoach.resource``
|
||||
# library so the admin can re-use a PDF / DOCX / link they already
|
||||
# uploaded under /admin/resources without re-uploading the binary.
|
||||
# The indexer dereferences the link at extraction time; the binary
|
||||
# itself stays in the library record so a single edit / rotation
|
||||
# propagates to every plan that grounds on it.
|
||||
('resource', 'Library resource'),
|
||||
]
|
||||
|
||||
SOURCE_STATUS_SELECTION = [
|
||||
@@ -56,6 +63,20 @@ class CoursePlanSource(models.Model):
|
||||
url = fields.Char(string='Source URL')
|
||||
inline_text = fields.Text(string='Inline text')
|
||||
|
||||
# Optional pointer to the central /admin/resources library. When set
|
||||
# the indexer pulls the binary / URL / inline text from the linked
|
||||
# ``encoach.resource`` instead of re-storing it on this row, which
|
||||
# avoids duplicating large PDFs across every plan that grounds on
|
||||
# the same library item.
|
||||
resource_id = fields.Many2one(
|
||||
'encoach.resource',
|
||||
string='Library resource',
|
||||
ondelete='set null',
|
||||
help='Link to a resource already uploaded under /admin/resources. '
|
||||
'The indexer reads the binary from the library at extraction '
|
||||
'time so updates to the library propagate to every plan.',
|
||||
)
|
||||
|
||||
auto_index = fields.Boolean(
|
||||
default=True,
|
||||
help='If true, indexing runs automatically on create. '
|
||||
@@ -74,18 +95,44 @@ class CoursePlanSource(models.Model):
|
||||
if rec.auto_index:
|
||||
try:
|
||||
rec.action_index()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# If indexing crashes before SourceIndexer has a chance
|
||||
# to mark the row as ``failed`` (e.g. an unexpected
|
||||
# import or DB error) we MUST still set the status to
|
||||
# ``failed``; otherwise the source sits in ``pending``
|
||||
# forever and the deliverables UI can't show progress.
|
||||
_logger.exception('Auto-index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
|
||||
def action_index(self):
|
||||
"""(Re-)extract text and push chunks to the vector store."""
|
||||
"""(Re-)extract text and push chunks to the vector store.
|
||||
|
||||
Wraps each per-record indexing call so a failure on one source
|
||||
doesn't abort the loop and leave later records orphaned.
|
||||
"""
|
||||
from odoo.addons.encoach_ai_course.services.source_indexer import (
|
||||
SourceIndexer,
|
||||
)
|
||||
indexer = SourceIndexer(self.env)
|
||||
for rec in self:
|
||||
indexer = SourceIndexer(self.env)
|
||||
indexer.index(rec)
|
||||
try:
|
||||
indexer.index(rec)
|
||||
except Exception as exc:
|
||||
_logger.exception('Index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def unlink(self):
|
||||
@@ -111,6 +158,11 @@ class CoursePlanSource(models.Model):
|
||||
'mime_type': self.mime_type or '',
|
||||
'url': self.url or '',
|
||||
'has_inline_text': bool(self.inline_text),
|
||||
'resource_id': self.resource_id.id if self.resource_id else None,
|
||||
'resource_name': self.resource_id.name if self.resource_id else '',
|
||||
'resource_type': (
|
||||
self.resource_id.type if self.resource_id else ''
|
||||
),
|
||||
'status': self.status or 'pending',
|
||||
'error': self.error or '',
|
||||
'chunks_count': self.chunks_count or 0,
|
||||
|
||||
Reference in New Issue
Block a user