import logging from odoo import models, fields, api _logger = logging.getLogger(__name__) class EncoachCourseChapter(models.Model): _name = 'encoach.course.chapter' _description = 'Course Chapter' _order = 'sequence, id' name = fields.Char(required=True) course_id = fields.Many2one('op.course', required=True, ondelete='cascade') sequence = fields.Integer(default=10) description = fields.Text() start_date = fields.Date() end_date = fields.Date() unlock_mode = fields.Selection([ ('auto_date', 'Automatic by Date'), ('manual', 'Manual'), ('prerequisite', 'Prerequisite'), ], default='manual') is_unlocked = fields.Boolean(default=True) topic_id = fields.Many2one('encoach.topic', ondelete='set null') domain_id = fields.Many2one( 'encoach.domain', compute='_compute_domain_id', store=True, ondelete='set null', string='Domain', ) learning_objective_ids = fields.Many2many( 'encoach.learning.objective', 'encoach_chapter_obj_rel', 'chapter_id', 'objective_id', string='Learning Objectives', ) material_ids = fields.One2many('encoach.chapter.material', 'chapter_id') material_count = fields.Integer(compute='_compute_material_count', store=True) @api.depends('topic_id', 'topic_id.domain_id') def _compute_domain_id(self): for rec in self: rec.domain_id = rec.topic_id.domain_id.id if rec.topic_id and rec.topic_id.domain_id else False @api.depends('material_ids') def _compute_material_count(self): for rec in self: rec.material_count = len(rec.material_ids) class EncoachChapterMaterial(models.Model): _name = 'encoach.chapter.material' _description = 'Chapter Material' _order = 'sequence, id' name = fields.Char(required=True) chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade') type = fields.Selection([ ('pdf', 'PDF'), ('document', 'Document'), ('video', 'Video'), ('audio', 'Audio'), ('image', 'Image'), ('link', 'Link'), ('article', 'Article'), ], default='pdf') file_data = fields.Binary(attachment=True) file_name = fields.Char() url = fields.Char() description = fields.Text() sequence = fields.Integer(default=10) allow_download = fields.Boolean(default=True) is_book = fields.Boolean(default=False) book_chapters = fields.Text() resource_id = fields.Many2one('encoach.resource', ondelete='set null', help="Auto-created library resource mirror") @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) records._vector_index() records._sync_library_resource() return records def write(self, vals): res = super().write(vals) if self.env.context.get('no_sync'): return res if any(f in vals for f in ('name', 'description', 'type')): self._vector_index() sync_fields = ('name', 'type', 'url', 'file_data') if any(f in vals for f in sync_fields): self._sync_library_resource() return res def unlink(self): self._vector_delete() resources = self.mapped('resource_id').filtered('exists') res = super().unlink() resources.unlink() return res def _sync_library_resource(self): """Create or update a matching encoach.resource for each material, propagating taxonomy.""" Res = self.env.get('encoach.resource') if Res is None: return Res = self.env['encoach.resource'].sudo() type_map = { 'pdf': 'pdf', 'document': 'document', 'video': 'video', 'audio': 'pdf', 'image': 'pdf', 'link': 'link', 'article': 'document', } for rec in self: vals = { 'name': rec.name, 'type': type_map.get(rec.type, 'document'), 'url': rec.url or '', 'review_status': 'approved', } if rec.file_data: vals['file'] = rec.file_data chapter = rec.chapter_id if chapter: if chapter.topic_id: vals['topic_ids'] = [(4, chapter.topic_id.id)] if chapter.domain_id: vals['domain_id'] = chapter.domain_id.id course = chapter.course_id if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: vals['subject_id'] = course.encoach_subject_id.id if chapter.learning_objective_ids: vals['learning_objective_ids'] = [(6, 0, chapter.learning_objective_ids.ids)] if rec.resource_id and rec.resource_id.exists(): rec.resource_id.write(vals) else: new_res = Res.create(vals) rec.with_context(no_sync=True).write({'resource_id': new_res.id}) def _vector_index(self): """Index material into the vector store for RAG retrieval with full taxonomy context.""" try: svc_mod = self.env.get('encoach.embedding') if svc_mod is None: return from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService svc = EmbeddingService(self.env) for rec in self: parts = [rec.name or ''] if rec.description: parts.append(str(rec.description)[:2000]) chapter = rec.chapter_id if chapter: if chapter.course_id: parts.append(f"Course: {chapter.course_id.name}") parts.append(f"Chapter: {chapter.name}") if chapter.topic_id: parts.append(f"Topic: {chapter.topic_id.name}") if chapter.domain_id: parts.append(f"Domain: {chapter.domain_id.name}") course = chapter.course_id if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: parts.append(f"Subject: {course.encoach_subject_id.name}") for obj in chapter.learning_objective_ids: parts.append(f"Objective: {obj.name}") text = ' '.join(p for p in parts if p).strip() if text: metadata = {'type': rec.type or 'pdf'} if chapter: metadata['chapter_id'] = chapter.id metadata['chapter_name'] = chapter.name or '' if chapter.course_id: metadata['course_id'] = chapter.course_id.id metadata['course_name'] = chapter.course_id.name or '' if chapter.topic_id: metadata['topic_id'] = chapter.topic_id.id metadata['topic_name'] = chapter.topic_id.name or '' if chapter.domain_id: metadata['domain_id'] = chapter.domain_id.id metadata['domain_name'] = chapter.domain_id.name or '' course = chapter.course_id if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: metadata['subject_id'] = course.encoach_subject_id.id metadata['subject_name'] = course.encoach_subject_id.name or '' if chapter.learning_objective_ids: metadata['objective_ids'] = chapter.learning_objective_ids.ids metadata['objective_names'] = chapter.learning_objective_ids.mapped('name') svc.upsert('material', rec.id, text, metadata) self.env.cr.commit() except Exception: _logger.warning("Failed to vector-index material(s)", exc_info=True) def _vector_delete(self): """Remove material embeddings from the vector store.""" try: svc_mod = self.env.get('encoach.embedding') if svc_mod is None: return from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService svc = EmbeddingService(self.env) for rec in self: svc.delete('material', rec.id) except Exception: _logger.warning("Failed to delete material vector(s)", exc_info=True) class EncoachChapterProgress(models.Model): _name = 'encoach.chapter.progress' _description = 'Chapter Progress' _rec_name = 'chapter_id' student_id = fields.Many2one('op.student', required=True, ondelete='cascade') chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade') status = fields.Selection([ ('not_started', 'Not Started'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ], default='not_started') started_at = fields.Datetime() completed_at = fields.Datetime() materials_completed = fields.Integer(default=0) materials_total = fields.Integer(default=0) class EncoachCourseCompletion(models.Model): _name = 'encoach.course.completion' _description = 'Course Completion' _rec_name = 'course_id' student_id = fields.Many2one('op.student', required=True, ondelete='cascade') course_id = fields.Many2one('op.course', required=True, ondelete='cascade') status = fields.Selection([ ('in_progress', 'In Progress'), ('completed', 'Completed'), ], default='in_progress') chapters_total = fields.Integer(default=0) chapters_completed = fields.Integer(default=0) progress_percent = fields.Integer(default=0) completed_at = fields.Datetime() post_test_available = fields.Boolean(default=False)