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
158 lines
5.9 KiB
Python
158 lines
5.9 KiB
Python
"""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.")
|