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

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

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

View File

@@ -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 '',

View File

@@ -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 '',

View File

@@ -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,

View File

@@ -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,