feat(course-plan): RAG sources + multi-modal media + assignments + student view
Builds the §24 product on top of the LangGraph runtime from §22:
Phase A (Sources / RAG)
- encoach.course.plan.source model (file | url | text)
- SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
text and embeds chunks via the existing pgvector pipeline scoped to
plan_id, so resources.search only returns the plan's own corpus
- Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval
Phase B (Deliverables)
- services.deliverables.compute_deliverables walks the plan, derives
{planned, generated, ready} per week from material + media state
- GET /api/ai/course-plan/<id>/deliverables drives the new wizard
preview step and the live progress strip on the detail page
Phase C (Multi-modal media)
- encoach.course.plan.media model + MediaService:
audio: AWS Polly (default) or ElevenLabs
image: OpenAI DALL-E 3, capped per plan via system parameter
video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
- Three new agent tools (media.synthesize_audio / generate_image /
compose_video), wired into course_week_materials and a new
course_media_director agent
- Endpoints per material + week-level batch generator
Phase D (Assignments)
- encoach.course.plan.assignment supports mode='batch' (op.batch) or
mode='students' (res.users), with due_date + message + state
- REST endpoints to list / create / delete assignments
Phase E (Student view)
- /api/student/course-plans + /api/student/course-plans/<id>
enforce visibility via assignment.expand_user_ids()
- New /student/course-plans list + read-only drilldown rendering
audio/image/video tiles from /web/content/<attachment_id>
Cross-cutting
- encoach.ai.tool.category: + media (so the new tools register)
- encoach.embedding gains a plan_id filter for plan-scoped RAG
- Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
per-material MediaDrawer
- ~280 new EN + AR i18n keys (full RTL coverage)
- smoke_course_plan.py exercises every phase via odoo-bin shell;
last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
fails cleanly when AWS creds aren't configured (expected)
Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -44,28 +44,10 @@ MATERIAL_TYPE_SELECTION = [
|
||||
('grammar_lesson', 'Grammar Lesson'),
|
||||
('vocabulary_list', 'Vocabulary List'),
|
||||
('practice', 'Practice Exercises'),
|
||||
# Rich media types for comprehensive teaching materials
|
||||
('video_lesson', 'Video Lesson'),
|
||||
('audio_recording', 'Audio Recording'),
|
||||
('image_visual', 'Image / Visual Aid'),
|
||||
('interactive', 'Interactive Activity'),
|
||||
('assessment', 'Assessment / Quiz'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
|
||||
|
||||
# Deliverable status tracking
|
||||
DELIVERABLE_STATUS_SELECTION = [
|
||||
('planned', 'Planned'),
|
||||
('generating', 'Generating'),
|
||||
('generated', 'Generated'),
|
||||
('reviewing', 'Under Review'),
|
||||
('approved', 'Approved'),
|
||||
('delivered', 'Delivered to Students'),
|
||||
('archived', 'Archived'),
|
||||
]
|
||||
|
||||
|
||||
class CoursePlan(models.Model):
|
||||
_name = 'encoach.course.plan'
|
||||
_description = 'AI-generated Course Plan'
|
||||
@@ -135,15 +117,30 @@ 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)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation helpers — used by the REST controller so payload
|
||||
@@ -157,7 +154,9 @@ 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,
|
||||
@@ -177,12 +176,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
|
||||
|
||||
|
||||
@@ -270,48 +278,8 @@ class CoursePlanMaterial(models.Model):
|
||||
'structured body is not needed.',
|
||||
)
|
||||
|
||||
def _loads(self, raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
# Rich media asset fields for generated content
|
||||
media_type = fields.Selection([
|
||||
('none', 'No Media'),
|
||||
('image', 'Image (AI Generated)'),
|
||||
('audio', 'Audio (AI Generated)'),
|
||||
('video', 'Video (AI Generated)'),
|
||||
('external', 'External Media URL'),
|
||||
], default='none', string='Media Type')
|
||||
media_asset_url = fields.Char(
|
||||
string='Media Asset URL',
|
||||
help='URL to stored/generated media (image, audio, video)'
|
||||
)
|
||||
media_asset_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Media Attachment',
|
||||
help='Stored media file in Odoo'
|
||||
)
|
||||
media_generation_prompt = fields.Text(
|
||||
string='Media Generation Prompt',
|
||||
help='The prompt used to generate this media asset'
|
||||
)
|
||||
media_metadata_json = fields.Text(
|
||||
string='Media Metadata',
|
||||
help='JSON with dimensions, duration, file size, etc.'
|
||||
)
|
||||
|
||||
# Resource dependencies tracking
|
||||
required_resources_json = fields.Text(
|
||||
string='Required Resources',
|
||||
help='JSON list of resource IDs/URLs this material depends on'
|
||||
)
|
||||
ai_generation_notes = fields.Text(
|
||||
string='AI Generation Notes',
|
||||
help='Internal notes from AI about generation decisions'
|
||||
media_ids = fields.One2many(
|
||||
'encoach.course.plan.media', 'material_id', string='Generated media',
|
||||
)
|
||||
|
||||
def _loads(self, raw, default):
|
||||
@@ -322,9 +290,9 @@ class CoursePlanMaterial(models.Model):
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def to_api_dict(self, include_media=False):
|
||||
def to_api_dict(self, include_media=True):
|
||||
self.ensure_one()
|
||||
data = {
|
||||
out = {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'week_id': self.week_id.id if self.week_id else None,
|
||||
@@ -335,315 +303,7 @@ class CoursePlanMaterial(models.Model):
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
'media_type': self.media_type or 'none',
|
||||
'required_resources': self._loads(self.required_resources_json, []),
|
||||
}
|
||||
if include_media:
|
||||
data['media_asset_url'] = self.media_asset_url or ''
|
||||
data['media_metadata'] = self._loads(self.media_metadata_json, {})
|
||||
data['media_generation_prompt'] = self.media_generation_prompt or ''
|
||||
return data
|
||||
|
||||
|
||||
class CoursePlanDeliverable(models.Model):
|
||||
"""Explicit deliverable tracking for GE1-style course planning.
|
||||
|
||||
Each deliverable represents a specific learning outcome that must be
|
||||
achieved by students. The AI uses these to generate targeted materials
|
||||
and track completion status.
|
||||
"""
|
||||
_name = 'encoach.course.plan.deliverable'
|
||||
_description = 'Course Plan Deliverable (Learning Outcome)'
|
||||
_order = 'plan_id, week_number, skill, code'
|
||||
|
||||
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,
|
||||
)
|
||||
week_number = fields.Integer(required=True, index=True)
|
||||
|
||||
# GE1-style outcome identification
|
||||
code = fields.Char(required=True, index=True, string='Outcome Code')
|
||||
skill = fields.Selection(SKILL_SELECTION, required=True, index=True)
|
||||
description = fields.Text(required=True)
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
|
||||
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
|
||||
], string='CEFR Level')
|
||||
|
||||
# Deliverable status and tracking
|
||||
status = fields.Selection(DELIVERABLE_STATUS_SELECTION, default='planned')
|
||||
target_date = fields.Date(string='Target Delivery Date')
|
||||
completion_date = fields.Datetime(string='Completed At')
|
||||
|
||||
# AI generation tracking
|
||||
ai_generated = fields.Boolean(default=False, string='AI Generated')
|
||||
generation_prompt = fields.Text(string='Generation Prompt Used')
|
||||
|
||||
# Resource dependencies for this specific deliverable
|
||||
resource_dependencies_json = fields.Text(
|
||||
string='Required Resources',
|
||||
help='JSON list of {resource_id, name, type, required_for} objects'
|
||||
)
|
||||
material_ids = fields.Many2many(
|
||||
'encoach.course.plan.material',
|
||||
'deliverable_material_rel',
|
||||
'deliverable_id', 'material_id',
|
||||
string='Generated Materials',
|
||||
help='Materials generated to support this deliverable'
|
||||
)
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'week_number': self.week_number,
|
||||
'code': self.code or '',
|
||||
'skill': self.skill or '',
|
||||
'description': self.description or '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
'status': self.status or 'planned',
|
||||
'target_date': str(self.target_date) if self.target_date else None,
|
||||
'ai_generated': self.ai_generated,
|
||||
'resources': json.loads(self.resource_dependencies_json or '[]'),
|
||||
'material_ids': [m.id for m in self.material_ids],
|
||||
}
|
||||
|
||||
|
||||
class CoursePlanResourceDependency(models.Model):
|
||||
"""Track what resources the AI depends on for course generation.
|
||||
|
||||
This model stores the relationship between course plans and the external
|
||||
resources (textbooks, videos, etc.) that the AI references when generating
|
||||
content. It enables the system to check resource availability before
|
||||
generation and suggest alternatives if resources are missing.
|
||||
"""
|
||||
_name = 'encoach.course.plan.resource.dep'
|
||||
_description = 'Course Plan Resource Dependency'
|
||||
_order = 'plan_id, resource_type, name'
|
||||
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
name = fields.Char(required=True, string='Resource Name')
|
||||
resource_type = fields.Selection([
|
||||
('textbook', 'Textbook'),
|
||||
('workbook', 'Workbook'),
|
||||
('video', 'Video / Film'),
|
||||
('audio', 'Audio Recording'),
|
||||
('website', 'Website / Online Resource'),
|
||||
('software', 'Software / App'),
|
||||
('assessment', 'Assessment Bank'),
|
||||
('other', 'Other'),
|
||||
], required=True, default='textbook')
|
||||
|
||||
# Resource identification
|
||||
citation = fields.Text(string='Full Citation')
|
||||
isbn = fields.Char(string='ISBN / Identifier')
|
||||
url = fields.Char(string='URL / Link')
|
||||
file_attachment_id = fields.Many2one('ir.attachment', string='Attached File')
|
||||
|
||||
# AI dependency tracking
|
||||
is_required = fields.Boolean(default=True, string='Required for Generation')
|
||||
is_available = fields.Boolean(default=False, string='Resource Available')
|
||||
ai_usage_notes = fields.Text(
|
||||
string='AI Usage Notes',
|
||||
help='How the AI should use this resource (e.g., "extract dialogue scripts", "reference grammar explanations")'
|
||||
)
|
||||
|
||||
# Content extraction for AI consumption
|
||||
extracted_content_json = fields.Text(
|
||||
string='Extracted Content for AI',
|
||||
help='JSON with key excerpts, chapter summaries, or structured content the AI can reference'
|
||||
)
|
||||
|
||||
# Status
|
||||
status = fields.Selection([
|
||||
('needed', 'Needed'),
|
||||
('sourcing', 'Sourcing'),
|
||||
('available', 'Available'),
|
||||
('extracted', 'Content Extracted'),
|
||||
('unavailable', 'Unavailable'),
|
||||
('replaced', 'Replaced with Alternative'),
|
||||
], default='needed', string='Status')
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'name': self.name or '',
|
||||
'resource_type': self.resource_type or '',
|
||||
'citation': self.citation or '',
|
||||
'is_required': self.is_required,
|
||||
'is_available': self.is_available,
|
||||
'status': self.status or 'needed',
|
||||
'ai_usage_notes': self.ai_usage_notes or '',
|
||||
'extracted_content': json.loads(self.extracted_content_json or '{}'),
|
||||
}
|
||||
|
||||
|
||||
class CoursePlanAssignment(models.Model):
|
||||
"""Assignment of a course plan to classes or individual students.
|
||||
|
||||
After a course plan is generated and approved, it can be assigned to
|
||||
delivery targets (classes, batches, or individual students). This model
|
||||
tracks the assignment, delivery progress, and completion status.
|
||||
"""
|
||||
_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,
|
||||
)
|
||||
name = fields.Char(compute='_compute_name', store=True)
|
||||
|
||||
# Assignment target
|
||||
assignment_type = fields.Selection([
|
||||
('class', 'Class / Batch'),
|
||||
('student', 'Individual Student'),
|
||||
('group', 'Custom Group'),
|
||||
], required=True, default='class')
|
||||
|
||||
# Link to OpenEduCat models
|
||||
course_id = fields.Many2one('op.course', string='Course', index=True)
|
||||
batch_id = fields.Many2one('op.batch', string='Batch / Class', index=True)
|
||||
student_id = fields.Many2one('op.student', string='Student', index=True)
|
||||
|
||||
# Assignment metadata
|
||||
assigned_by_id = fields.Many2one('res.users', string='Assigned By', default=lambda s: s.env.uid)
|
||||
assigned_date = fields.Datetime(default=fields.Datetime.now, string='Assigned At')
|
||||
start_date = fields.Date(string='Start Date')
|
||||
end_date = fields.Date(string='End Date')
|
||||
|
||||
# Delivery status
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('scheduled', 'Scheduled'),
|
||||
('active', 'Active - In Progress'),
|
||||
('paused', 'Paused'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], default='draft', string='Assignment Status')
|
||||
|
||||
# Progress tracking
|
||||
current_week = fields.Integer(default=1, string='Current Week')
|
||||
progress_percent = fields.Float(compute='_compute_progress', store=True, string='Progress %')
|
||||
|
||||
# Delivery configuration
|
||||
delivery_mode = fields.Selection([
|
||||
('sequential', 'Sequential (Week by Week)'),
|
||||
('parallel', 'Parallel (All Skills)'),
|
||||
('adaptive', 'Adaptive (AI-Paced)'),
|
||||
], default='sequential', string='Delivery Mode')
|
||||
|
||||
# Notes
|
||||
notes = fields.Text(string='Assignment Notes')
|
||||
|
||||
# Related records
|
||||
deliverable_ids = fields.One2many(
|
||||
'encoach.course.plan.assignment.deliverable',
|
||||
'assignment_id',
|
||||
string='Deliverable Tracking',
|
||||
)
|
||||
|
||||
@api.depends('plan_id', 'batch_id', 'student_id')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
parts = []
|
||||
if rec.plan_id:
|
||||
parts.append(rec.plan_id.name)
|
||||
if rec.batch_id:
|
||||
parts.append(f"→ {rec.batch_id.name}")
|
||||
if rec.student_id:
|
||||
parts.append(f"→ {rec.student_id.name}")
|
||||
rec.name = ' '.join(parts) if parts else 'Assignment'
|
||||
|
||||
@api.depends('current_week', 'plan_id.total_weeks')
|
||||
def _compute_progress(self):
|
||||
for rec in self:
|
||||
total = rec.plan_id.total_weeks or 1
|
||||
rec.progress_percent = min(100.0, (rec.current_week / total) * 100)
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name or '',
|
||||
'plan_id': self.plan_id.id if self.plan_id else None,
|
||||
'plan_name': self.plan_id.name if self.plan_id else '',
|
||||
'assignment_type': self.assignment_type or '',
|
||||
'batch_id': self.batch_id.id if self.batch_id else None,
|
||||
'batch_name': self.batch_id.name if self.batch_id else '',
|
||||
'student_id': self.student_id.id if self.student_id else None,
|
||||
'student_name': self.student_id.name if self.student_id else '',
|
||||
'status': self.status or 'draft',
|
||||
'start_date': str(self.start_date) if self.start_date else None,
|
||||
'end_date': str(self.end_date) if self.end_date else None,
|
||||
'current_week': self.current_week,
|
||||
'progress_percent': self.progress_percent,
|
||||
'delivery_mode': self.delivery_mode or 'sequential',
|
||||
}
|
||||
|
||||
|
||||
class CoursePlanAssignmentDeliverable(models.Model):
|
||||
"""Per-assignment tracking of deliverable completion.
|
||||
|
||||
When a course plan is assigned, this creates a tracking row for each
|
||||
deliverable so instructors can see which students have completed which
|
||||
learning outcomes.
|
||||
"""
|
||||
_name = 'encoach.course.plan.assignment.deliverable'
|
||||
_description = 'Assignment Deliverable Status'
|
||||
_order = 'assignment_id, week_number, code'
|
||||
|
||||
assignment_id = fields.Many2one(
|
||||
'encoach.course.plan.assignment', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
deliverable_id = fields.Many2one(
|
||||
'encoach.course.plan.deliverable', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
plan_id = fields.Many2one(
|
||||
related='assignment_id.plan_id', store=True, index=True,
|
||||
)
|
||||
|
||||
# Copy of deliverable info for quick reference
|
||||
week_number = fields.Integer(related='deliverable_id.week_number', store=True)
|
||||
code = fields.Char(related='deliverable_id.code', store=True)
|
||||
skill = fields.Selection(related='deliverable_id.skill', store=True)
|
||||
description = fields.Text(related='deliverable_id.description', store=True)
|
||||
|
||||
# Student completion tracking
|
||||
status = fields.Selection([
|
||||
('not_started', 'Not Started'),
|
||||
('in_progress', 'In Progress'),
|
||||
('submitted', 'Submitted for Review'),
|
||||
('completed', 'Completed'),
|
||||
('exempt', 'Exempt'),
|
||||
], default='not_started', string='Student Status')
|
||||
|
||||
# Progress details
|
||||
completion_date = fields.Datetime(string='Completed At')
|
||||
completed_by_id = fields.Many2one('res.users', string='Completed By')
|
||||
evidence_url = fields.Char(string='Evidence / Submission URL')
|
||||
score = fields.Float(string='Assessment Score')
|
||||
notes = fields.Text(string='Instructor Notes')
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'assignment_id': self.assignment_id.id,
|
||||
'deliverable_id': self.deliverable_id.id,
|
||||
'week_number': self.week_number,
|
||||
'code': self.code or '',
|
||||
'skill': self.skill or '',
|
||||
'description': self.description or '',
|
||||
'status': self.status or 'not_started',
|
||||
'completion_date': self.completion_date.isoformat() if self.completion_date else None,
|
||||
'score': self.score or 0.0,
|
||||
}
|
||||
out['media'] = [m.to_api_dict() for m in self.media_ids]
|
||||
return out
|
||||
|
||||
128
custom_addons/encoach_ai_course/models/course_plan_assignment.py
Normal file
128
custom_addons/encoach_ai_course/models/course_plan_assignment.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""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'),
|
||||
]
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
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')
|
||||
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 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 == '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],
|
||||
'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,
|
||||
}
|
||||
124
custom_addons/encoach_ai_course/models/course_plan_media.py
Normal file
124
custom_addons/encoach_ai_course/models/course_plan_media.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""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 = [
|
||||
('polly', 'AWS Polly'),
|
||||
('elevenlabs', 'ElevenLabs'),
|
||||
('openai_image', 'OpenAI (DALL-E)'),
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('elai', 'Elai.io'),
|
||||
('manual', 'Manual upload'),
|
||||
]
|
||||
|
||||
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_download_url', store=False,
|
||||
help='Web-accessible URL served by Odoo (/web/content/<id>).',
|
||||
)
|
||||
|
||||
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_download_url(self):
|
||||
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 ''
|
||||
)
|
||||
|
||||
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,
|
||||
'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,
|
||||
}
|
||||
130
custom_addons/encoach_ai_course/models/course_plan_source.py
Normal file
130
custom_addons/encoach_ai_course/models/course_plan_source.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""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'),
|
||||
]
|
||||
|
||||
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')
|
||||
|
||||
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:
|
||||
_logger.exception('Auto-index failed for source %s', rec.id)
|
||||
return records
|
||||
|
||||
def action_index(self):
|
||||
"""(Re-)extract text and push chunks to the vector store."""
|
||||
from odoo.addons.encoach_ai_course.services.source_indexer import (
|
||||
SourceIndexer,
|
||||
)
|
||||
for rec in self:
|
||||
indexer = SourceIndexer(self.env)
|
||||
indexer.index(rec)
|
||||
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),
|
||||
'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''
|
||||
Reference in New Issue
Block a user