Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs. Backend (new `encoach_lms_api` addon + existing addons): - Institutional: academic years/terms, departments, admission registers & admissions, courses/batches, lessons, fees (terms + student fees + invoicing with income-account auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset), student leave, result templates + marksheets (incl. delete-with-cascade). - Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived from `op.student.fees.details` and `account.move`; platform settings backed by `encoach.code` and `ir.config_parameter` (packages + grading config). - Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models) with CRUD, pagination, search/level filters, and upsert-style progress endpoints. Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`; `ValidationError`/`UserError` mapped to HTTP 400. Frontend: - Rewire institutional admin pages (Academic Year Manager, Admissions, Courses, Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets, Taxonomy, Resources) to real APIs with React Query invalidation and dialogs. - New typed services: `payments.service.ts`, `platformSettings.service.ts`, `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/ resources/student-progress/generation` services + related types. - Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`, `TicketsPage` to consume live data with search/filter/progress/CRUD flows. - New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`. - Favicons/branding assets and misc. UX polish across teacher/student pages. Tooling & QA: - Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent, covers institutional + support + training fixtures incl. income-account wiring). - API write-flow test suites: `test_write_flows.py` (institutional), `test_support_flows.py` (support), `test_training_flows.py` (training), `test_ai_full.py`. All suites pass end-to-end. - Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA. - `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/`. Made-with: Cursor
236 lines
9.9 KiB
Python
236 lines
9.9 KiB
Python
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)
|