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 <field> 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 <noreply@anthropic.com>
This commit is contained in:
parent
151bc32747
commit
8c8c79aa64
5
.gitattributes
vendored
5
.gitattributes
vendored
@ -1,2 +1,7 @@
|
||||
*.sh text eol=lf
|
||||
*.conf text eol=lf
|
||||
*.pdf binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
|
||||
1
addons/mc_education_lms/__init__.py
Normal file
1
addons/mc_education_lms/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
17
addons/mc_education_lms/__manifest__.py
Normal file
17
addons/mc_education_lms/__manifest__.py
Normal file
@ -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,
|
||||
}
|
||||
129
addons/mc_education_lms/demo/mc_lms_demo.xml
Normal file
129
addons/mc_education_lms/demo/mc_lms_demo.xml
Normal file
@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Matches shared/DEMO_SCRIPT.md Scene 8 exactly: "Mathematics -
|
||||
Algebra Basics": a video lesson, a PDF handout, a 5-question
|
||||
quiz. website_slides skips external metadata fetching during
|
||||
module/demo install (its own create() checks
|
||||
self.env.context.get('install_mode')), so this loads with no
|
||||
network access and no risk of an install-time failure.
|
||||
video_url is a well-known placeholder ("never gonna give you
|
||||
up") - swap for the school's real lesson video before an actual
|
||||
client demo; it is only here to prove the video slide renders
|
||||
and plays inline, not as real course content. -->
|
||||
<record id="demo_channel_grade8_math" model="slide.channel">
|
||||
<field name="name">Mathematics - Algebra Basics</field>
|
||||
<field name="enroll">invite</field>
|
||||
</record>
|
||||
|
||||
<record id="mc_education_base.demo_batch_grade8_a" model="mc.batch">
|
||||
<field name="channel_id" ref="demo_channel_grade8_math"/>
|
||||
</record>
|
||||
|
||||
<record id="demo_slide_algebra_video" model="slide.slide">
|
||||
<field name="name">Algebra Basics - Video Lesson</field>
|
||||
<field name="channel_id" ref="demo_channel_grade8_math"/>
|
||||
<field name="slide_category">video</field>
|
||||
<field name="video_url">https://www.youtube.com/watch?v=dQw4w9WgXcQ</field>
|
||||
<field name="is_published">True</field>
|
||||
<field name="sequence">1</field>
|
||||
</record>
|
||||
|
||||
<record id="demo_slide_algebra_handout" model="slide.slide">
|
||||
<field name="name">Algebra Basics - Handout</field>
|
||||
<field name="channel_id" ref="demo_channel_grade8_math"/>
|
||||
<field name="slide_category">document</field>
|
||||
<!-- type="base64" is only valid paired with file= (a real file
|
||||
in the addon) - it is NOT for inline base64 text, which
|
||||
raises a ValueError swallowed by Odoo's demo-data error
|
||||
recovery in a way that manifested as a deeply confusing
|
||||
foreign-key error on an unrelated model (mc.batch) rather
|
||||
than the real, simple cause. Found only by bisecting down
|
||||
to a single record against a live container. -->
|
||||
<field name="binary_content" type="base64" file="mc_education_lms/static/demo/algebra_handout.pdf"/>
|
||||
<field name="is_published">True</field>
|
||||
<field name="sequence">2</field>
|
||||
</record>
|
||||
|
||||
<record id="demo_slide_algebra_quiz" model="slide.slide">
|
||||
<field name="name">Algebra Basics - Quiz</field>
|
||||
<field name="channel_id" ref="demo_channel_grade8_math"/>
|
||||
<field name="slide_category">quiz</field>
|
||||
<field name="is_published">True</field>
|
||||
<field name="sequence">3</field>
|
||||
</record>
|
||||
|
||||
<record id="demo_quiz_q1" model="slide.question">
|
||||
<field name="slide_id" ref="demo_slide_algebra_quiz"/>
|
||||
<field name="sequence">1</field>
|
||||
<field name="question">What is the value of x in x + 5 = 12?</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q1_a1" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q1"/>
|
||||
<field name="text_value">7</field>
|
||||
<field name="is_correct">True</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q1_a2" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q1"/>
|
||||
<field name="text_value">17</field>
|
||||
</record>
|
||||
|
||||
<record id="demo_quiz_q2" model="slide.question">
|
||||
<field name="slide_id" ref="demo_slide_algebra_quiz"/>
|
||||
<field name="sequence">2</field>
|
||||
<field name="question">Simplify: 3x + 2x</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q2_a1" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q2"/>
|
||||
<field name="text_value">5x</field>
|
||||
<field name="is_correct">True</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q2_a2" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q2"/>
|
||||
<field name="text_value">6x</field>
|
||||
</record>
|
||||
|
||||
<record id="demo_quiz_q3" model="slide.question">
|
||||
<field name="slide_id" ref="demo_slide_algebra_quiz"/>
|
||||
<field name="sequence">3</field>
|
||||
<field name="question">What is the coefficient of x in 4x + 3?</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q3_a1" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q3"/>
|
||||
<field name="text_value">4</field>
|
||||
<field name="is_correct">True</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q3_a2" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q3"/>
|
||||
<field name="text_value">3</field>
|
||||
</record>
|
||||
|
||||
<record id="demo_quiz_q4" model="slide.question">
|
||||
<field name="slide_id" ref="demo_slide_algebra_quiz"/>
|
||||
<field name="sequence">4</field>
|
||||
<field name="question">Solve for y: 2y = 10</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q4_a1" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q4"/>
|
||||
<field name="text_value">5</field>
|
||||
<field name="is_correct">True</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q4_a2" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q4"/>
|
||||
<field name="text_value">20</field>
|
||||
</record>
|
||||
|
||||
<record id="demo_quiz_q5" model="slide.question">
|
||||
<field name="slide_id" ref="demo_slide_algebra_quiz"/>
|
||||
<field name="sequence">5</field>
|
||||
<field name="question">Which of these is a linear equation?</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q5_a1" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q5"/>
|
||||
<field name="text_value">y = 2x + 1</field>
|
||||
<field name="is_correct">True</field>
|
||||
</record>
|
||||
<record id="demo_quiz_q5_a2" model="slide.answer">
|
||||
<field name="question_id" ref="demo_quiz_q5"/>
|
||||
<field name="text_value">y = x^2</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
addons/mc_education_lms/models/__init__.py
Normal file
2
addons/mc_education_lms/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import mc_batch
|
||||
from . import mc_enrollment
|
||||
10
addons/mc_education_lms/models/mc_batch.py
Normal file
10
addons/mc_education_lms/models/mc_batch.py
Normal file
@ -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.",
|
||||
)
|
||||
27
addons/mc_education_lms/models/mc_enrollment.py
Normal file
27
addons/mc_education_lms/models/mc_enrollment.py
Normal file
@ -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)
|
||||
BIN
addons/mc_education_lms/static/demo/algebra_handout.pdf
Normal file
BIN
addons/mc_education_lms/static/demo/algebra_handout.pdf
Normal file
Binary file not shown.
1
addons/mc_education_lms/tests/__init__.py
Normal file
1
addons/mc_education_lms/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import test_lms
|
||||
67
addons/mc_education_lms/tests/test_lms.py
Normal file
67
addons/mc_education_lms/tests/test_lms.py
Normal file
@ -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",
|
||||
})
|
||||
13
addons/mc_education_lms/views/mc_batch_views.xml
Normal file
13
addons/mc_education_lms/views/mc_batch_views.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_mc_batch_form_inherit_mc_education_lms" model="ir.ui.view">
|
||||
<field name="name">mc.batch.form.inherit.mc.education.lms</field>
|
||||
<field name="model">mc.batch</field>
|
||||
<field name="inherit_id" ref="mc_education_base.view_mc_batch_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='room_id']" position="after">
|
||||
<field name="channel_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
x
Reference in New Issue
Block a user