Serves Demo Scene 4: mc.attendance (student x date x session, unique constraint so resubmission can never duplicate) plus a bulk-marking wizard - pick a batch/date/session, the roster loads pre-filled Present (or whatever was already recorded, if reopening), tap to change an exception, Submit. Submitting again for the same batch/date/session updates the same rows rather than creating duplicates - verified with a real test that marks a batch present, then reopens and corrects one student, then asserts there are still exactly two rows, not three. The "teachers may only mark their own batches" rule is a record rule (ir.rule scoped to group_teacher via batch_id.class_teacher_id. user_id), not a UI check, per CLAUDE.md sec 3 and the O4 spec line verbatim. Verified for real, not just declared: a teacher user who is not the class teacher of a batch gets AccessError on create *and* on reading an existing attendance row by id directly (the actual "cannot open another teacher's batch by editing the URL" scenario), while Administrator remains unrestricted since the rule's `groups` field scopes it to teacher only. Caught two Odoo 19 search-view schema changes while installing against a live odoo:19.0 container - a plain read of the view XML wouldn't have caught these, only trying to actually load it did: neither the group-by `<group>` element nor the filter groups inside a `<search>` view accept a `string` or `expand` attribute anymore (confirmed against hr's own search views, which use bare `<group>`). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
5.0 KiB
Python
116 lines
5.0 KiB
Python
from psycopg2 import IntegrityError
|
|
|
|
from odoo.tests.common import TransactionCase
|
|
from odoo.tools import mute_logger
|
|
|
|
|
|
class TestAttendance(TransactionCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.year = cls.env["mc.academic.year"].create({
|
|
"name": "TEST-ATT-2026-27",
|
|
"date_start": "2026-06-01", "date_end": "2027-04-30",
|
|
})
|
|
cls.program = cls.env["mc.program"].create({
|
|
"name": "TEST ATT Program", "code": "TEST-ATT-P1", "sequence_no": 1,
|
|
"display_label": "Test Grade",
|
|
})
|
|
cls.batch = cls.env["mc.batch"].create({
|
|
"name": "TEST ATT Batch", "program_id": cls.program.id, "year_id": cls.year.id,
|
|
})
|
|
partner1 = cls.env["res.partner"].create({"name": "ATT Student One"})
|
|
cls.student1 = cls.env["mc.student"].create({
|
|
"partner_id": partner1.id, "name": "ATT Student One",
|
|
})
|
|
partner2 = cls.env["res.partner"].create({"name": "ATT Student Two"})
|
|
cls.student2 = cls.env["mc.student"].create({
|
|
"partner_id": partner2.id, "name": "ATT 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",
|
|
})
|
|
|
|
@mute_logger("odoo.sql_db")
|
|
def test_duplicate_student_date_session_rejected(self):
|
|
self.env["mc.attendance"].create({
|
|
"student_id": self.student1.id, "batch_id": self.batch.id,
|
|
"date": "2026-09-01", "session": "Daily", "state": "present",
|
|
})
|
|
with self.assertRaises(IntegrityError):
|
|
with self.cr.savepoint():
|
|
self.env["mc.attendance"].create({
|
|
"student_id": self.student1.id, "batch_id": self.batch.id,
|
|
"date": "2026-09-01", "session": "Daily", "state": "absent",
|
|
})
|
|
|
|
def test_different_session_same_day_is_allowed(self):
|
|
self.env["mc.attendance"].create({
|
|
"student_id": self.student1.id, "batch_id": self.batch.id,
|
|
"date": "2026-09-01", "session": "Daily", "state": "present",
|
|
})
|
|
# Should not raise: different session on the same date/student.
|
|
self.env["mc.attendance"].create({
|
|
"student_id": self.student1.id, "batch_id": self.batch.id,
|
|
"date": "2026-09-01", "session": "Period 2", "state": "absent",
|
|
})
|
|
|
|
def test_wizard_loads_active_enrollments_as_roster(self):
|
|
wizard = self.env["mc.attendance.bulk.wizard"].create({
|
|
"batch_id": self.batch.id, "date": "2026-09-02", "session": "Daily",
|
|
})
|
|
wizard._onchange_load_roster()
|
|
self.assertEqual(set(wizard.line_ids.mapped("student_id")), {self.student1, self.student2})
|
|
self.assertTrue(all(line.state == "present" for line in wizard.line_ids))
|
|
|
|
def test_wizard_submit_creates_attendance(self):
|
|
wizard = self.env["mc.attendance.bulk.wizard"].create({
|
|
"batch_id": self.batch.id, "date": "2026-09-03", "session": "Daily",
|
|
})
|
|
wizard._onchange_load_roster()
|
|
wizard.line_ids.filtered(lambda l: l.student_id == self.student2).state = "absent"
|
|
wizard.action_submit()
|
|
|
|
records = self.env["mc.attendance"].search([
|
|
("batch_id", "=", self.batch.id), ("date", "=", "2026-09-03"),
|
|
])
|
|
self.assertEqual(len(records), 2)
|
|
absent = records.filtered(lambda a: a.student_id == self.student2)
|
|
self.assertEqual(absent.state, "absent")
|
|
|
|
def test_resubmitting_wizard_is_idempotent(self):
|
|
# First submission: mark everyone present.
|
|
wizard1 = self.env["mc.attendance.bulk.wizard"].create({
|
|
"batch_id": self.batch.id, "date": "2026-09-04", "session": "Daily",
|
|
})
|
|
wizard1._onchange_load_roster()
|
|
wizard1.action_submit()
|
|
self.assertEqual(
|
|
self.env["mc.attendance"].search_count([
|
|
("batch_id", "=", self.batch.id), ("date", "=", "2026-09-04"),
|
|
]),
|
|
2,
|
|
)
|
|
|
|
# Re-open and correct one student to late - must update the same
|
|
# two rows, never create new ones.
|
|
wizard2 = self.env["mc.attendance.bulk.wizard"].create({
|
|
"batch_id": self.batch.id, "date": "2026-09-04", "session": "Daily",
|
|
})
|
|
wizard2._onchange_load_roster()
|
|
wizard2.line_ids.filtered(lambda l: l.student_id == self.student1).state = "late"
|
|
wizard2.action_submit()
|
|
|
|
records = self.env["mc.attendance"].search([
|
|
("batch_id", "=", self.batch.id), ("date", "=", "2026-09-04"),
|
|
])
|
|
self.assertEqual(len(records), 2)
|
|
late = records.filtered(lambda a: a.student_id == self.student1)
|
|
self.assertEqual(late.state, "late")
|