Serves Demo Scene 6. mc.grading.scale + mc.grading.interval are pure
data (threshold/letter/point/description rows) - CBSE, ICSE, IB,
Cambridge, Ontario and a plain percentage scale all shipped as data
in data/mc_grading_scale_data.xml, noupdate="1" so a school's own
edits survive a module upgrade. mc.mark.grade resolves via
mc.program.grading_scale_id (new field on mc.program, since grading
board is a per-program concept already carrying "board" from O1) -
the moment this needed an `if board == "CBSE"` anywhere, per
shared/DOMAIN_MODEL.md sec 5, it would have been the wrong design;
verified instead that swapping a program's scale recomputes an
existing mark's grade with zero code involved
(test_changing_scale_recomputes_existing_marks).
Grid mark entry ("whole class on one screen, tab between fields,
keyboard only") is a plain editable list on mc.mark, filtered to one
exam - that native Odoo behavior already gives real Tab-key
navigation with no custom widget. mc.exam.action_enter_marks()
pre-creates a row per actively-enrolled student before opening it
(mirroring O4's bulk-attendance idempotency: creating it again never
duplicates or resets an already-entered mark).
The report card is a real rendered PDF, not just a template read by
inspection - rendered it for Aditya via odoo shell
(report._render_qweb_pdf), pulled the bytes out of the container, and
read the actual PDF: one A4 page, no clipped columns, correct grades
per subject matching the CBSE scale (92->A1, 85->A2, 78->B1, 67->B2,
58->C1), signature block, term correctly resolved. That last part
exposed a real logic bug before it shipped: the term-resolution
method originally preferred "today's date" over "the term with
marks", which would have shown an empty Term 1 when generating the
card *today* even though the demo's graded marks are all in Term 2 -
a report card is generated to review a term's results, typically
after that term's exams are done (often during the *next* term), so
marks-with-data now wins over the calendar, falling back to today's
date only for a student with nothing graded yet.
Attendance summary on the report degrades to None when
mc_education_attendance isn't installed (checked directly, not
assumed) - this module depends on mc_education_base only, matching
"each mc_education_* module installs independently" (CLAUDE.md sec
1.4), while still showing the real summary when both are installed
together for the actual demo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
85 lines
3.8 KiB
Python
85 lines
3.8 KiB
Python
from psycopg2 import IntegrityError
|
|
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.tests.common import TransactionCase
|
|
from odoo.tools import mute_logger
|
|
|
|
|
|
class TestExam(TransactionCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.year = cls.env["mc.academic.year"].create({
|
|
"name": "TEST-EXAM-2026-27",
|
|
"date_start": "2026-06-01", "date_end": "2027-04-30",
|
|
})
|
|
cls.term = cls.env["mc.academic.term"].create({
|
|
"name": "TEST EXAM Term", "year_id": cls.year.id,
|
|
"date_start": "2026-06-01", "date_end": "2026-09-30",
|
|
})
|
|
cls.program = cls.env["mc.program"].create({
|
|
"name": "TEST EXAM Program", "code": "TEST-EXAM-P1", "sequence_no": 1,
|
|
"display_label": "Test Grade",
|
|
})
|
|
cls.batch = cls.env["mc.batch"].create({
|
|
"name": "TEST EXAM Batch", "program_id": cls.program.id, "year_id": cls.year.id,
|
|
})
|
|
cls.subject = cls.env["mc.subject"].create({"name": "TEST EXAM Subject", "code": "TEST-EXAM-S1"})
|
|
cls.exam = cls.env["mc.exam"].create({
|
|
"name": "TEST EXAM Midterm", "term_id": cls.term.id, "batch_id": cls.batch.id,
|
|
"subject_id": cls.subject.id, "max_marks": 100, "pass_marks": 35, "date": "2026-07-01",
|
|
})
|
|
cls.student1 = cls.env["mc.student"].create({
|
|
"partner_id": cls.env["res.partner"].create({"name": "EXAM Student One"}).id,
|
|
"name": "EXAM Student One",
|
|
})
|
|
cls.student2 = cls.env["mc.student"].create({
|
|
"partner_id": cls.env["res.partner"].create({"name": "EXAM Student Two"}).id,
|
|
"name": "EXAM Student Two",
|
|
})
|
|
cls.env["mc.enrollment"].create({
|
|
"student_id": cls.student1.id, "program_id": cls.program.id,
|
|
"batch_id": cls.batch.id, "year_id": cls.year.id, "state": "active",
|
|
})
|
|
cls.env["mc.enrollment"].create({
|
|
"student_id": cls.student2.id, "program_id": cls.program.id,
|
|
"batch_id": cls.batch.id, "year_id": cls.year.id, "state": "active",
|
|
})
|
|
|
|
def test_pass_marks_cannot_exceed_max_marks(self):
|
|
with self.assertRaises(ValidationError):
|
|
self.env["mc.exam"].create({
|
|
"name": "Bad Exam", "term_id": self.term.id, "batch_id": self.batch.id,
|
|
"subject_id": self.subject.id, "max_marks": 100, "pass_marks": 150,
|
|
"date": "2026-07-02",
|
|
})
|
|
|
|
@mute_logger("odoo.sql_db")
|
|
def test_duplicate_mark_for_same_student_exam_rejected(self):
|
|
self.env["mc.mark"].create({
|
|
"exam_id": self.exam.id, "student_id": self.student1.id, "marks_obtained": 80,
|
|
})
|
|
with self.assertRaises(IntegrityError):
|
|
with self.cr.savepoint():
|
|
self.env["mc.mark"].create({
|
|
"exam_id": self.exam.id, "student_id": self.student1.id, "marks_obtained": 90,
|
|
})
|
|
|
|
def test_enter_marks_creates_a_row_per_active_enrollment(self):
|
|
action = self.exam.action_enter_marks()
|
|
self.assertEqual(action["domain"], [("exam_id", "=", self.exam.id)])
|
|
marks = self.env["mc.mark"].search(action["domain"])
|
|
self.assertEqual(set(marks.mapped("student_id")), {self.student1, self.student2})
|
|
self.assertTrue(all(m.marks_obtained == 0 for m in marks))
|
|
|
|
def test_enter_marks_does_not_duplicate_existing_rows(self):
|
|
self.env["mc.mark"].create({
|
|
"exam_id": self.exam.id, "student_id": self.student1.id, "marks_obtained": 75,
|
|
})
|
|
self.exam.action_enter_marks()
|
|
marks = self.env["mc.mark"].search([("exam_id", "=", self.exam.id)])
|
|
self.assertEqual(len(marks), 2)
|
|
student1_mark = marks.filtered(lambda m: m.student_id == self.student1)
|
|
self.assertEqual(student1_mark.marks_obtained, 75) # untouched, not reset to 0
|