From 8c8c79aa64771f4fc0684f09c9d4054a9674efc0 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 11 Sep 2026 14:01:06 -0400 Subject: [PATCH] O8: mc_education_lms - thin glue linking a batch to a course channel Serves Demo Scene 8. Per spec this is glue only, not a custom LMS: mc.batch gets one new field (channel_id -> slide.channel), and mc.enrollment's create()/write() calls the stock slide.channel._action_add_members() when an enrollment becomes active for a batch that has a channel - that's the entire feature. 40 lines of model code, well under the spec's own "~200 lines or something has gone wrong" ceiling. Demo data matches Scene 8's script exactly - "Mathematics - Algebra Basics": a video lesson, a PDF handout, a 5-question quiz (verified: each question has exactly one correct and one incorrect answer, the minimum website_slides itself requires). This module took far longer to get right than its size suggests, and the reason is worth recording. A ForeignKeyViolation on an unrelated model (mc.batch referencing a channel Postgres said was never inserted, despite that channel being created earlier in the same file) sent the investigation looking for an install-time flush- ordering bug for a long time - checking whether attachment=True binary fields interact badly with a pending FK write in the same flush batch, splitting the demo data across multiple files, even routing the batch-channel link through a post_init_hook to sidestep it. All of that was chasing a symptom. Bisecting the actual XML down to a single record eventually surfaced the real, simple cause: type="base64" on an XML is only valid paired with a file= attribute pointing to a real file in the addon - inline base64 text raises a ValueError that Odoo's demo-data loader catches and downgrades to "installed without demo data", and in an earlier configuration (batch-link in the same file) that same swallowed error surfaced instead as the confusing FK violation. Fixed by saving the handout as a real file (static/demo/algebra_handout.pdf) and referencing it properly; the post_init_hook and file-splitting were reverted since the real fix needed neither. Verified byte-for-byte: the attachment Odoo stores is exactly 604 bytes, detected as application/pdf, matching the source file's md5sum. Also caught before it reached git: line-ending conversion on this binary PDF ("LF will be replaced by CRLF"), which would have silently corrupted it on checkout for any contributor with Windows's core.autocrlf on. Added *.pdf (and common image types) as binary to .gitattributes and confirmed the staged blob's md5sum matches the source file exactly. Co-Authored-By: Claude Sonnet 5 --- .gitattributes | 5 + addons/mc_education_lms/__init__.py | 1 + addons/mc_education_lms/__manifest__.py | 17 +++ addons/mc_education_lms/demo/mc_lms_demo.xml | 129 ++++++++++++++++++ addons/mc_education_lms/models/__init__.py | 2 + addons/mc_education_lms/models/mc_batch.py | 10 ++ .../mc_education_lms/models/mc_enrollment.py | 27 ++++ .../static/demo/algebra_handout.pdf | Bin 0 -> 604 bytes addons/mc_education_lms/tests/__init__.py | 1 + addons/mc_education_lms/tests/test_lms.py | 67 +++++++++ .../mc_education_lms/views/mc_batch_views.xml | 13 ++ 11 files changed, 272 insertions(+) create mode 100644 addons/mc_education_lms/__init__.py create mode 100644 addons/mc_education_lms/__manifest__.py create mode 100644 addons/mc_education_lms/demo/mc_lms_demo.xml create mode 100644 addons/mc_education_lms/models/__init__.py create mode 100644 addons/mc_education_lms/models/mc_batch.py create mode 100644 addons/mc_education_lms/models/mc_enrollment.py create mode 100644 addons/mc_education_lms/static/demo/algebra_handout.pdf create mode 100644 addons/mc_education_lms/tests/__init__.py create mode 100644 addons/mc_education_lms/tests/test_lms.py create mode 100644 addons/mc_education_lms/views/mc_batch_views.xml diff --git a/.gitattributes b/.gitattributes index 21f6efb..91d5d9d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ *.sh text eol=lf *.conf text eol=lf +*.pdf binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary diff --git a/addons/mc_education_lms/__init__.py b/addons/mc_education_lms/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/mc_education_lms/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/mc_education_lms/__manifest__.py b/addons/mc_education_lms/__manifest__.py new file mode 100644 index 0000000..d414a81 --- /dev/null +++ b/addons/mc_education_lms/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "School ERP - LMS", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Thin glue linking a batch to a website_slides course channel.", + "author": "Metatroncube Software Solutions LLP", + "license": "Other proprietary", + "depends": ["mc_education_base", "website_slides"], + "data": [ + "views/mc_batch_views.xml", + ], + "demo": [ + "demo/mc_lms_demo.xml", + ], + "installable": True, + "application": False, +} diff --git a/addons/mc_education_lms/demo/mc_lms_demo.xml b/addons/mc_education_lms/demo/mc_lms_demo.xml new file mode 100644 index 0000000..d3e3e0f --- /dev/null +++ b/addons/mc_education_lms/demo/mc_lms_demo.xml @@ -0,0 +1,129 @@ + + + + + Mathematics - Algebra Basics + invite + + + + + + + + Algebra Basics - Video Lesson + + video + https://www.youtube.com/watch?v=dQw4w9WgXcQ + True + 1 + + + + Algebra Basics - Handout + + document + + + True + 2 + + + + Algebra Basics - Quiz + + quiz + True + 3 + + + + + 1 + What is the value of x in x + 5 = 12? + + + + 7 + True + + + + 17 + + + + + 2 + Simplify: 3x + 2x + + + + 5x + True + + + + 6x + + + + + 3 + What is the coefficient of x in 4x + 3? + + + + 4 + True + + + + 3 + + + + + 4 + Solve for y: 2y = 10 + + + + 5 + True + + + + 20 + + + + + 5 + Which of these is a linear equation? + + + + y = 2x + 1 + True + + + + y = x^2 + + diff --git a/addons/mc_education_lms/models/__init__.py b/addons/mc_education_lms/models/__init__.py new file mode 100644 index 0000000..b8133f5 --- /dev/null +++ b/addons/mc_education_lms/models/__init__.py @@ -0,0 +1,2 @@ +from . import mc_batch +from . import mc_enrollment diff --git a/addons/mc_education_lms/models/mc_batch.py b/addons/mc_education_lms/models/mc_batch.py new file mode 100644 index 0000000..84870db --- /dev/null +++ b/addons/mc_education_lms/models/mc_batch.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class McBatch(models.Model): + _inherit = "mc.batch" + + channel_id = fields.Many2one( + "slide.channel", string="Course Channel", ondelete="set null", + help="Enrolling a student in this batch enrolls them in this course channel.", + ) diff --git a/addons/mc_education_lms/models/mc_enrollment.py b/addons/mc_education_lms/models/mc_enrollment.py new file mode 100644 index 0000000..d26168f --- /dev/null +++ b/addons/mc_education_lms/models/mc_enrollment.py @@ -0,0 +1,27 @@ +from odoo import api, models + + +class McEnrollment(models.Model): + _inherit = "mc.enrollment" + + @api.model_create_multi + def create(self, vals_list): + enrollments = super().create(vals_list) + enrollments.filtered(lambda e: e.state == "active")._sync_lms_channel_membership() + return enrollments + + def write(self, vals): + res = super().write(vals) + if "state" in vals or "batch_id" in vals: + self.filtered(lambda e: e.state == "active")._sync_lms_channel_membership() + return res + + def _sync_lms_channel_membership(self): + # Enrolling in the batch is what enrolls the student in its + # course channel - the whole point of this module (O8 spec: + # "link a channel to a mc.batch so enrolled students see their + # courses"). Stock website_slides API, nothing custom. + for enrollment in self: + channel = enrollment.batch_id.channel_id + if channel: + channel._action_add_members(enrollment.student_id.partner_id) diff --git a/addons/mc_education_lms/static/demo/algebra_handout.pdf b/addons/mc_education_lms/static/demo/algebra_handout.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d0df64d0c59d7281a14813686e9081e9552f8a58 GIT binary patch literal 604 zcmZWm%WlFj5WM><_JY(NsO^x1BB4lyLXlbog4|FKhAbf{B$e${v|rzK!b@~7mS<;n z$K%%gb=;OuK9eAzT<`mR9{+qHA1bTzGUaoXq5+SZizvb(AqsQ18){AA?^$A29mo6R z!?G$Y$cwwL6fa8%4}mjq1P#F*up$L%fA9Z4v_F1Rh@U6xUk8K$&b~pbZm6 zudVDO#CsUUXB~NKbYa-&-n2>(o?)u&4iCzb_$(nm&C-)GXZ0oq_XfVK*4E|iLYJJD zV+~I*|3;dnE*Ty*ZW)Ec#HAn%f=&QixMDr1yHH%QfFgEvgGoWZeTRASKh_B;%QMev imF1`z#lFmb5kkHwOZw#|e~MRWTbuN~fVEnq+4v8LJ*K4q literal 0 HcmV?d00001 diff --git a/addons/mc_education_lms/tests/__init__.py b/addons/mc_education_lms/tests/__init__.py new file mode 100644 index 0000000..d0b38d2 --- /dev/null +++ b/addons/mc_education_lms/tests/__init__.py @@ -0,0 +1 @@ +from . import test_lms diff --git a/addons/mc_education_lms/tests/test_lms.py b/addons/mc_education_lms/tests/test_lms.py new file mode 100644 index 0000000..bedbe4e --- /dev/null +++ b/addons/mc_education_lms/tests/test_lms.py @@ -0,0 +1,67 @@ +from odoo.tests.common import TransactionCase + + +class TestLms(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.year = cls.env["mc.academic.year"].create({ + "name": "TEST-LMS-2026-27", + "date_start": "2026-06-01", "date_end": "2027-04-30", + }) + cls.program = cls.env["mc.program"].create({ + "name": "TEST LMS Program", "code": "TEST-LMS-P1", "sequence_no": 1, + "display_label": "Test Grade", + }) + cls.channel = cls.env["slide.channel"].create({"name": "TEST LMS Channel"}) + cls.batch = cls.env["mc.batch"].create({ + "name": "TEST LMS Batch", "program_id": cls.program.id, "year_id": cls.year.id, + "channel_id": cls.channel.id, + }) + partner = cls.env["res.partner"].create({"name": "LMS Test Student"}) + cls.student = cls.env["mc.student"].create({ + "partner_id": partner.id, "name": "LMS Test Student", + }) + + def test_active_enrollment_joins_the_batchs_channel(self): + self.env["mc.enrollment"].create({ + "student_id": self.student.id, "program_id": self.program.id, + "batch_id": self.batch.id, "year_id": self.year.id, "state": "active", + }) + member = self.env["slide.channel.partner"].search([ + ("channel_id", "=", self.channel.id), ("partner_id", "=", self.student.partner_id.id), + ]) + self.assertTrue(member) + self.assertEqual(member.member_status, "joined") + + def test_draft_enrollment_does_not_join_the_channel(self): + self.env["mc.enrollment"].create({ + "student_id": self.student.id, "program_id": self.program.id, + "batch_id": self.batch.id, "year_id": self.year.id, "state": "draft", + }) + member = self.env["slide.channel.partner"].search([ + ("channel_id", "=", self.channel.id), ("partner_id", "=", self.student.partner_id.id), + ]) + self.assertFalse(member) + + def test_activating_a_draft_enrollment_joins_the_channel(self): + enrollment = self.env["mc.enrollment"].create({ + "student_id": self.student.id, "program_id": self.program.id, + "batch_id": self.batch.id, "year_id": self.year.id, "state": "draft", + }) + enrollment.write({"state": "active"}) + member = self.env["slide.channel.partner"].search([ + ("channel_id", "=", self.channel.id), ("partner_id", "=", self.student.partner_id.id), + ]) + self.assertTrue(member) + + def test_batch_without_channel_does_not_error(self): + batch_no_channel = self.env["mc.batch"].create({ + "name": "TEST LMS Batch No Channel", "program_id": self.program.id, "year_id": self.year.id, + }) + # Should not raise. + self.env["mc.enrollment"].create({ + "student_id": self.student.id, "program_id": self.program.id, + "batch_id": batch_no_channel.id, "year_id": self.year.id, "state": "active", + }) diff --git a/addons/mc_education_lms/views/mc_batch_views.xml b/addons/mc_education_lms/views/mc_batch_views.xml new file mode 100644 index 0000000..9a6c646 --- /dev/null +++ b/addons/mc_education_lms/views/mc_batch_views.xml @@ -0,0 +1,13 @@ + + + + mc.batch.form.inherit.mc.education.lms + mc.batch + + + + + + + +