feat: institutional + support + training admin sections (backend + frontend)
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
This commit is contained in:
11
custom_addons/encoach_lms_api/models/__init__.py
Normal file
11
custom_addons/encoach_lms_api/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from . import lesson
|
||||
from . import student_leave
|
||||
from . import gradebook
|
||||
from . import communication
|
||||
from . import courseware
|
||||
from . import notification
|
||||
from . import faq
|
||||
from . import course_ext
|
||||
from . import asset
|
||||
from . import ticket
|
||||
from . import training
|
||||
13
custom_addons/encoach_lms_api/models/asset.py
Normal file
13
custom_addons/encoach_lms_api/models/asset.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class EncoachAsset(models.Model):
|
||||
_name = 'encoach.asset'
|
||||
_description = 'Institutional Asset (lightweight)'
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
code = fields.Char('Code')
|
||||
facility_id = fields.Many2one('op.facility', string='Facility')
|
||||
description = fields.Text('Description')
|
||||
active = fields.Boolean('Active', default=True)
|
||||
69
custom_addons/encoach_lms_api/models/communication.py
Normal file
69
custom_addons/encoach_lms_api/models/communication.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachDiscussionBoard(models.Model):
|
||||
_name = 'encoach.discussion.board'
|
||||
_description = 'Discussion Board'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='cascade')
|
||||
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
||||
chapter_id = fields.Many2one('encoach.course.chapter', ondelete='set null')
|
||||
is_enabled = fields.Boolean(default=True)
|
||||
allow_student_posts = fields.Boolean(default=True)
|
||||
post_ids = fields.One2many('encoach.discussion.post', 'board_id')
|
||||
post_count = fields.Integer(compute='_compute_post_count', store=True)
|
||||
|
||||
def _compute_post_count(self):
|
||||
for rec in self:
|
||||
rec.post_count = len(rec.post_ids)
|
||||
|
||||
|
||||
class EncoachDiscussionPost(models.Model):
|
||||
_name = 'encoach.discussion.post'
|
||||
_description = 'Discussion Post'
|
||||
_order = 'create_date desc'
|
||||
|
||||
board_id = fields.Many2one('encoach.discussion.board', required=True, ondelete='cascade')
|
||||
parent_id = fields.Many2one('encoach.discussion.post', string='Parent', ondelete='cascade')
|
||||
child_ids = fields.One2many('encoach.discussion.post', 'parent_id', string='Replies')
|
||||
author_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||
title = fields.Char()
|
||||
content = fields.Text(required=True)
|
||||
is_pinned = fields.Boolean(default=False)
|
||||
is_resolved = fields.Boolean(default=False)
|
||||
|
||||
|
||||
class EncoachAnnouncement(models.Model):
|
||||
_name = 'encoach.announcement'
|
||||
_description = 'Announcement'
|
||||
_order = 'create_date desc'
|
||||
|
||||
title = fields.Char(required=True)
|
||||
content = fields.Text(required=True)
|
||||
author_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||
course_id = fields.Many2one('op.course', ondelete='set null')
|
||||
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
||||
priority = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('important', 'Important'),
|
||||
('urgent', 'Urgent'),
|
||||
], default='normal')
|
||||
is_published = fields.Boolean(default=False)
|
||||
published_at = fields.Datetime()
|
||||
expires_at = fields.Datetime()
|
||||
send_email = fields.Boolean(default=False)
|
||||
|
||||
|
||||
class EncoachMessage(models.Model):
|
||||
_name = 'encoach.message'
|
||||
_description = 'Direct Message'
|
||||
_order = 'create_date desc'
|
||||
|
||||
sender_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||
recipient_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||
subject = fields.Char(required=True)
|
||||
content = fields.Text(required=True)
|
||||
is_read = fields.Boolean(default=False)
|
||||
read_at = fields.Datetime()
|
||||
send_email_copy = fields.Boolean(default=False)
|
||||
56
custom_addons/encoach_lms_api/models/course_ext.py
Normal file
56
custom_addons/encoach_lms_api/models/course_ext.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class OpCourseExt(models.Model):
|
||||
_inherit = 'op.course'
|
||||
|
||||
description = fields.Text('Description')
|
||||
max_capacity = fields.Integer('Max Capacity', default=30)
|
||||
|
||||
encoach_subject_id = fields.Many2one(
|
||||
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
|
||||
)
|
||||
encoach_topic_ids = fields.Many2many(
|
||||
'encoach.topic', 'op_course_encoach_topic_rel',
|
||||
'course_id', 'topic_id', string='Topics',
|
||||
)
|
||||
learning_objective_ids = fields.Many2many(
|
||||
'encoach.learning.objective', 'op_course_learning_obj_rel',
|
||||
'course_id', 'objective_id', string='Learning Objectives',
|
||||
)
|
||||
encoach_tag_ids = fields.Many2many(
|
||||
'encoach.resource.tag', 'op_course_resource_tag_rel',
|
||||
'course_id', 'tag_id', string='Tags',
|
||||
)
|
||||
difficulty_level = fields.Selection([
|
||||
('beginner', 'Beginner'),
|
||||
('intermediate', 'Intermediate'),
|
||||
('advanced', 'Advanced'),
|
||||
])
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
|
||||
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
|
||||
])
|
||||
|
||||
chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters')
|
||||
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
|
||||
objective_count = fields.Integer(compute='_compute_objective_count')
|
||||
resource_count = fields.Integer(compute='_compute_resource_count')
|
||||
|
||||
@api.depends('chapter_ids')
|
||||
def _compute_chapter_count(self):
|
||||
for rec in self:
|
||||
rec.chapter_count = len(rec.chapter_ids)
|
||||
|
||||
def _compute_objective_count(self):
|
||||
for rec in self:
|
||||
rec.objective_count = len(rec.learning_objective_ids)
|
||||
|
||||
def _compute_resource_count(self):
|
||||
Mat = self.env['encoach.chapter.material'].sudo()
|
||||
for rec in self:
|
||||
mats = Mat.search([
|
||||
('chapter_id.course_id', '=', rec.id),
|
||||
('resource_id', '!=', False),
|
||||
])
|
||||
rec.resource_count = len(mats.mapped('resource_id'))
|
||||
235
custom_addons/encoach_lms_api/models/courseware.py
Normal file
235
custom_addons/encoach_lms_api/models/courseware.py
Normal file
@@ -0,0 +1,235 @@
|
||||
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)
|
||||
41
custom_addons/encoach_lms_api/models/faq.py
Normal file
41
custom_addons/encoach_lms_api/models/faq.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachFaqCategory(models.Model):
|
||||
_name = 'encoach.faq.category'
|
||||
_description = 'FAQ Category'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
audience = fields.Selection([
|
||||
('all', 'All'),
|
||||
('student', 'Students'),
|
||||
('teacher', 'Teachers'),
|
||||
('admin', 'Admins'),
|
||||
], default='all')
|
||||
is_published = fields.Boolean(default=True)
|
||||
item_ids = fields.One2many('encoach.faq.item', 'category_id')
|
||||
item_count = fields.Integer(compute='_compute_item_count', store=True)
|
||||
|
||||
def _compute_item_count(self):
|
||||
for rec in self:
|
||||
rec.item_count = len(rec.item_ids)
|
||||
|
||||
|
||||
class EncoachFaqItem(models.Model):
|
||||
_name = 'encoach.faq.item'
|
||||
_description = 'FAQ Item'
|
||||
_order = 'sequence, id'
|
||||
|
||||
category_id = fields.Many2one('encoach.faq.category', required=True, ondelete='cascade')
|
||||
question = fields.Char(required=True)
|
||||
answer = fields.Text(required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
audience = fields.Selection([
|
||||
('all', 'All'),
|
||||
('student', 'Students'),
|
||||
('teacher', 'Teachers'),
|
||||
('admin', 'Admins'),
|
||||
], default='all')
|
||||
is_published = fields.Boolean(default=True)
|
||||
26
custom_addons/encoach_lms_api/models/gradebook.py
Normal file
26
custom_addons/encoach_lms_api/models/gradebook.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachGradebook(models.Model):
|
||||
_name = 'encoach.gradebook'
|
||||
_description = 'Gradebook'
|
||||
|
||||
student_id = fields.Many2one('op.student', required=True, ondelete='cascade')
|
||||
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
|
||||
academic_year_id = fields.Many2one('op.academic.year', ondelete='set null')
|
||||
line_ids = fields.One2many('encoach.gradebook.line', 'gradebook_id')
|
||||
|
||||
|
||||
class EncoachGradebookLine(models.Model):
|
||||
_name = 'encoach.gradebook.line'
|
||||
_description = 'Gradebook Line'
|
||||
|
||||
gradebook_id = fields.Many2one('encoach.gradebook', required=True, ondelete='cascade')
|
||||
assignment_name = fields.Char()
|
||||
marks = fields.Float()
|
||||
percentage = fields.Float()
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('submitted', 'Submitted'),
|
||||
('graded', 'Graded'),
|
||||
], default='draft')
|
||||
24
custom_addons/encoach_lms_api/models/lesson.py
Normal file
24
custom_addons/encoach_lms_api/models/lesson.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachLesson(models.Model):
|
||||
_name = 'encoach.lesson'
|
||||
_description = 'Lesson'
|
||||
_order = 'start_datetime desc, id desc'
|
||||
|
||||
name = fields.Char(string='Name')
|
||||
lesson_topic = fields.Char(string='Topic')
|
||||
course_id = fields.Many2one('op.course', string='Course', ondelete='cascade')
|
||||
batch_id = fields.Many2one('op.batch', string='Batch', ondelete='set null')
|
||||
subject_id = fields.Many2one('op.subject', string='Subject', ondelete='set null')
|
||||
session_id = fields.Many2one('op.session', string='Session', ondelete='set null')
|
||||
faculty_id = fields.Many2one('op.faculty', string='Faculty', ondelete='set null')
|
||||
start_datetime = fields.Datetime(string='Start')
|
||||
end_datetime = fields.Datetime(string='End')
|
||||
content = fields.Text(string='Content')
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('scheduled', 'Scheduled'),
|
||||
('done', 'Done'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], default='draft', string='Status')
|
||||
54
custom_addons/encoach_lms_api/models/notification.py
Normal file
54
custom_addons/encoach_lms_api/models/notification.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachNotification(models.Model):
|
||||
_name = 'encoach.notification'
|
||||
_description = 'Notification'
|
||||
_order = 'create_date desc'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
title = fields.Char(required=True)
|
||||
message = fields.Text()
|
||||
type = fields.Selection([
|
||||
('info', 'Info'),
|
||||
('warning', 'Warning'),
|
||||
('success', 'Success'),
|
||||
('error', 'Error'),
|
||||
], default='info')
|
||||
is_read = fields.Boolean(default=False)
|
||||
read_at = fields.Datetime()
|
||||
reference_model = fields.Char()
|
||||
reference_id = fields.Integer()
|
||||
|
||||
|
||||
class EncoachNotificationRule(models.Model):
|
||||
_name = 'encoach.notification.rule'
|
||||
_description = 'Notification Rule'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
event_type = fields.Char(required=True)
|
||||
template = fields.Text()
|
||||
is_active = fields.Boolean(default=True)
|
||||
recipients = fields.Selection([
|
||||
('all', 'All Users'),
|
||||
('students', 'Students Only'),
|
||||
('teachers', 'Teachers Only'),
|
||||
('admins', 'Admins Only'),
|
||||
], default='all')
|
||||
channels = fields.Char(help='Comma-separated: email,push,in_app')
|
||||
|
||||
|
||||
class EncoachNotificationPreference(models.Model):
|
||||
_name = 'encoach.notification.preference'
|
||||
_description = 'Notification Preference'
|
||||
_rec_name = 'user_id'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
email_enabled = fields.Boolean(default=True)
|
||||
push_enabled = fields.Boolean(default=True)
|
||||
in_app_enabled = fields.Boolean(default=True)
|
||||
digest_frequency = fields.Selection([
|
||||
('realtime', 'Real-time'),
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
], default='realtime')
|
||||
46
custom_addons/encoach_lms_api/models/student_leave.py
Normal file
46
custom_addons/encoach_lms_api/models/student_leave.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class EncoachStudentLeaveType(models.Model):
|
||||
_name = 'encoach.student.leave.type'
|
||||
_description = 'Student Leave Type'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
|
||||
|
||||
class EncoachStudentLeave(models.Model):
|
||||
_name = 'encoach.student.leave'
|
||||
_description = 'Student Leave Request'
|
||||
_order = 'create_date desc'
|
||||
|
||||
request_number = fields.Char(string='Request #', readonly=True, copy=False)
|
||||
student_id = fields.Many2one('op.student', required=True, ondelete='cascade')
|
||||
leave_type_id = fields.Many2one('encoach.student.leave.type', required=True, ondelete='restrict')
|
||||
start_date = fields.Date(required=True)
|
||||
end_date = fields.Date(required=True)
|
||||
duration = fields.Integer(compute='_compute_duration', store=True)
|
||||
description = fields.Text()
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('confirm', 'Confirmed'),
|
||||
('approve', 'Approved'),
|
||||
('refuse', 'Refused'),
|
||||
('cancel', 'Cancelled'),
|
||||
], default='draft')
|
||||
approve_date = fields.Date()
|
||||
approved_by = fields.Many2one('res.users', ondelete='set null')
|
||||
|
||||
@api.depends('start_date', 'end_date')
|
||||
def _compute_duration(self):
|
||||
for rec in self:
|
||||
if rec.start_date and rec.end_date:
|
||||
rec.duration = (rec.end_date - rec.start_date).days + 1
|
||||
else:
|
||||
rec.duration = 0
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('request_number'):
|
||||
vals['request_number'] = self.env['ir.sequence'].next_by_code('encoach.student.leave') or 'SL-NEW'
|
||||
return super().create(vals_list)
|
||||
37
custom_addons/encoach_lms_api/models/ticket.py
Normal file
37
custom_addons/encoach_lms_api/models/ticket.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class EncoachTicket(models.Model):
|
||||
_name = 'encoach.ticket'
|
||||
_description = 'Support Ticket'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'id desc'
|
||||
|
||||
subject = fields.Char('Subject', required=True, tracking=True)
|
||||
description = fields.Text('Description')
|
||||
type = fields.Selection([
|
||||
('bug', 'Bug'),
|
||||
('feature', 'Feature'),
|
||||
('help', 'Help'),
|
||||
('support', 'Support'),
|
||||
('feedback', 'Feedback'),
|
||||
], string='Type', default='support', required=True, tracking=True)
|
||||
status = fields.Selection([
|
||||
('open', 'Open'),
|
||||
('in_progress', 'In Progress'),
|
||||
('resolved', 'Resolved'),
|
||||
('closed', 'Closed'),
|
||||
], string='Status', default='open', required=True, tracking=True)
|
||||
source = fields.Selection([
|
||||
('platform', 'Platform'),
|
||||
('webpage', 'Webpage'),
|
||||
('email', 'Email'),
|
||||
], string='Source', default='platform')
|
||||
|
||||
page_context = fields.Char('Page Context')
|
||||
reporter_id = fields.Many2one('res.users', string='Reporter', required=True,
|
||||
default=lambda self: self.env.user.id)
|
||||
assignee_id = fields.Many2one('res.users', string='Assignee')
|
||||
entity_id = fields.Many2one('encoach.entity', string='Entity')
|
||||
|
||||
resolved_at = fields.Datetime('Resolved At')
|
||||
157
custom_addons/encoach_lms_api/models/training.py
Normal file
157
custom_addons/encoach_lms_api/models/training.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Training library: vocabulary items, grammar rules and per-user progress.
|
||||
|
||||
Backs the `/admin/training/vocabulary` and `/admin/training/grammar` pages
|
||||
(previously hard-coded mocks).
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
CEFR_LEVELS = [
|
||||
('A1', 'A1'),
|
||||
('A2', 'A2'),
|
||||
('B1', 'B1'),
|
||||
('B2', 'B2'),
|
||||
('C1', 'C1'),
|
||||
('C2', 'C2'),
|
||||
]
|
||||
|
||||
|
||||
class EncoachVocabItem(models.Model):
|
||||
_name = 'encoach.vocab.item'
|
||||
_description = 'Training Vocabulary Item'
|
||||
_order = 'level,word'
|
||||
|
||||
word = fields.Char('Word', required=True, index=True)
|
||||
meaning = fields.Text('Meaning', required=True)
|
||||
example_sentence = fields.Text('Example Sentence')
|
||||
level = fields.Selection(CEFR_LEVELS, string='CEFR Level', default='B1', required=True)
|
||||
part_of_speech = fields.Selection([
|
||||
('noun', 'Noun'),
|
||||
('verb', 'Verb'),
|
||||
('adjective', 'Adjective'),
|
||||
('adverb', 'Adverb'),
|
||||
('phrase', 'Phrase'),
|
||||
('other', 'Other'),
|
||||
], string='Part of Speech', default='noun')
|
||||
category = fields.Char('Category', default='general',
|
||||
help='Tag such as "academic", "business", "daily".')
|
||||
active = fields.Boolean('Active', default=True)
|
||||
|
||||
progress_ids = fields.One2many('encoach.vocab.progress', 'vocab_id', 'Progress')
|
||||
|
||||
completion_count = fields.Integer('Completions', compute='_compute_stats')
|
||||
learners_count = fields.Integer('Learners', compute='_compute_stats')
|
||||
|
||||
@api.depends('progress_ids.completed')
|
||||
def _compute_stats(self):
|
||||
for rec in self:
|
||||
rec.learners_count = len(rec.progress_ids)
|
||||
rec.completion_count = len(rec.progress_ids.filtered(lambda p: p.completed))
|
||||
|
||||
@api.constrains('word')
|
||||
def _check_word_unique(self):
|
||||
for rec in self:
|
||||
if not rec.word:
|
||||
continue
|
||||
dup = self.sudo().search([
|
||||
('id', '!=', rec.id),
|
||||
('word', '=ilike', rec.word.strip()),
|
||||
], limit=1)
|
||||
if dup:
|
||||
from odoo.exceptions import ValidationError
|
||||
raise ValidationError(f"Word '{rec.word}' already exists.")
|
||||
|
||||
|
||||
class EncoachVocabProgress(models.Model):
|
||||
_name = 'encoach.vocab.progress'
|
||||
_description = 'Vocabulary Progress'
|
||||
_order = 'last_reviewed desc'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True,
|
||||
default=lambda self: self.env.user.id,
|
||||
ondelete='cascade')
|
||||
vocab_id = fields.Many2one('encoach.vocab.item', required=True, ondelete='cascade')
|
||||
completed = fields.Boolean('Completed', default=False)
|
||||
mastery = fields.Selection([
|
||||
('learning', 'Learning'),
|
||||
('familiar', 'Familiar'),
|
||||
('mastered', 'Mastered'),
|
||||
], default='learning')
|
||||
last_reviewed = fields.Datetime('Last Reviewed')
|
||||
review_count = fields.Integer('Review Count', default=0)
|
||||
|
||||
@api.constrains('user_id', 'vocab_id')
|
||||
def _check_unique_user_vocab(self):
|
||||
for rec in self:
|
||||
dup = self.sudo().search([
|
||||
('id', '!=', rec.id),
|
||||
('user_id', '=', rec.user_id.id),
|
||||
('vocab_id', '=', rec.vocab_id.id),
|
||||
], limit=1)
|
||||
if dup:
|
||||
from odoo.exceptions import ValidationError
|
||||
raise ValidationError("Progress already exists for this user + vocabulary item.")
|
||||
|
||||
|
||||
class EncoachGrammarRule(models.Model):
|
||||
_name = 'encoach.grammar.rule'
|
||||
_description = 'Training Grammar Rule'
|
||||
_order = 'level,name'
|
||||
|
||||
name = fields.Char('Rule', required=True, index=True)
|
||||
description = fields.Text('Description', required=True)
|
||||
example = fields.Text('Example')
|
||||
level = fields.Selection(CEFR_LEVELS, string='CEFR Level', default='B1', required=True)
|
||||
category = fields.Char('Category', default='general',
|
||||
help='Tag such as "tenses", "modals", "clauses".')
|
||||
active = fields.Boolean('Active', default=True)
|
||||
|
||||
progress_ids = fields.One2many('encoach.grammar.progress', 'rule_id', 'Progress')
|
||||
|
||||
completion_count = fields.Integer('Completions', compute='_compute_stats')
|
||||
learners_count = fields.Integer('Learners', compute='_compute_stats')
|
||||
|
||||
@api.depends('progress_ids.completed')
|
||||
def _compute_stats(self):
|
||||
for rec in self:
|
||||
rec.learners_count = len(rec.progress_ids)
|
||||
rec.completion_count = len(rec.progress_ids.filtered(lambda p: p.completed))
|
||||
|
||||
@api.constrains('name')
|
||||
def _check_rule_name_unique(self):
|
||||
for rec in self:
|
||||
if not rec.name:
|
||||
continue
|
||||
dup = self.sudo().search([
|
||||
('id', '!=', rec.id),
|
||||
('name', '=ilike', rec.name.strip()),
|
||||
], limit=1)
|
||||
if dup:
|
||||
from odoo.exceptions import ValidationError
|
||||
raise ValidationError(f"Grammar rule '{rec.name}' already exists.")
|
||||
|
||||
|
||||
class EncoachGrammarProgress(models.Model):
|
||||
_name = 'encoach.grammar.progress'
|
||||
_description = 'Grammar Rule Progress'
|
||||
_order = 'last_reviewed desc'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True,
|
||||
default=lambda self: self.env.user.id,
|
||||
ondelete='cascade')
|
||||
rule_id = fields.Many2one('encoach.grammar.rule', required=True, ondelete='cascade')
|
||||
completed = fields.Boolean('Completed', default=False)
|
||||
last_reviewed = fields.Datetime('Last Reviewed')
|
||||
review_count = fields.Integer('Review Count', default=0)
|
||||
|
||||
@api.constrains('user_id', 'rule_id')
|
||||
def _check_unique_user_rule(self):
|
||||
for rec in self:
|
||||
dup = self.sudo().search([
|
||||
('id', '!=', rec.id),
|
||||
('user_id', '=', rec.user_id.id),
|
||||
('rule_id', '=', rec.rule_id.id),
|
||||
], limit=1)
|
||||
if dup:
|
||||
from odoo.exceptions import ValidationError
|
||||
raise ValidationError("Progress already exists for this user + grammar rule.")
|
||||
Reference in New Issue
Block a user