diff --git a/addons/mc_education_attendance/__init__.py b/addons/mc_education_attendance/__init__.py
new file mode 100644
index 0000000..aee8895
--- /dev/null
+++ b/addons/mc_education_attendance/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizards
diff --git a/addons/mc_education_attendance/__manifest__.py b/addons/mc_education_attendance/__manifest__.py
new file mode 100644
index 0000000..2d1f714
--- /dev/null
+++ b/addons/mc_education_attendance/__manifest__.py
@@ -0,0 +1,21 @@
+{
+ "name": "School ERP - Attendance",
+ "version": "19.0.1.0.0",
+ "category": "Education",
+ "summary": "Mobile-first bulk attendance marking, one teacher's own batches only.",
+ "author": "Metatroncube Software Solutions LLP",
+ "license": "Other proprietary",
+ "depends": ["mc_education_base"],
+ "data": [
+ "security/mc_education_attendance_security.xml",
+ "security/ir.model.access.csv",
+ "views/mc_attendance_views.xml",
+ "views/mc_attendance_bulk_wizard_views.xml",
+ "views/mc_education_attendance_menus.xml",
+ ],
+ "demo": [
+ "demo/mc_attendance_demo.xml",
+ ],
+ "installable": True,
+ "application": False,
+}
diff --git a/addons/mc_education_attendance/demo/mc_attendance_demo.xml b/addons/mc_education_attendance/demo/mc_attendance_demo.xml
new file mode 100644
index 0000000..3c51268
--- /dev/null
+++ b/addons/mc_education_attendance/demo/mc_attendance_demo.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+ 2026-09-01
+ present
+
+
+
+
+ 2026-09-02
+ present
+
+
+
+
+ 2026-09-03
+ absent
+
+
+
+
+ 2026-09-04
+ present
+
+
+
+
+ 2026-09-07
+ late
+
+
+
+
+ 2026-09-08
+ present
+
+
+
+
+ 2026-09-09
+ present
+
+
+
+
+ 2026-09-10
+ present
+
+
+
+
+
+ 2026-09-01
+ present
+
+
+
+
+ 2026-09-02
+ present
+
+
+
+
+ 2026-09-03
+ present
+
+
+
+
+ 2026-09-04
+ on_leave
+
+
+
+
+ 2026-09-07
+ present
+
+
+
+
+ 2026-09-08
+ present
+
+
+
+
+ 2026-09-09
+ present
+
+
+
+
+ 2026-09-10
+ present
+
+
diff --git a/addons/mc_education_attendance/models/__init__.py b/addons/mc_education_attendance/models/__init__.py
new file mode 100644
index 0000000..597ef7e
--- /dev/null
+++ b/addons/mc_education_attendance/models/__init__.py
@@ -0,0 +1 @@
+from . import mc_attendance
diff --git a/addons/mc_education_attendance/models/mc_attendance.py b/addons/mc_education_attendance/models/mc_attendance.py
new file mode 100644
index 0000000..faa5d0e
--- /dev/null
+++ b/addons/mc_education_attendance/models/mc_attendance.py
@@ -0,0 +1,42 @@
+from odoo import api, fields, models
+
+
+class McAttendance(models.Model):
+ _name = "mc.attendance"
+ _inherit = ["mail.thread"]
+ _description = "Attendance"
+ _order = "date desc, student_id"
+ _rec_name = "display_name"
+
+ student_id = fields.Many2one("mc.student", string="Student", required=True, ondelete="restrict")
+ batch_id = fields.Many2one("mc.batch", string="Batch", required=True, ondelete="restrict")
+ date = fields.Date(string="Date", required=True, default=fields.Date.context_today)
+ session = fields.Char(
+ string="Session", required=True, default="Daily",
+ help='e.g. "Daily" for a single roll call, or "Period 2" for period-wise attendance.',
+ )
+ subject_id = fields.Many2one("mc.subject", string="Subject", ondelete="restrict")
+ state = fields.Selection(
+ [
+ ("present", "Present"),
+ ("absent", "Absent"),
+ ("late", "Late"),
+ ("on_leave", "On Leave"),
+ ],
+ string="Status", required=True, default="present", tracking=True,
+ )
+ marked_by = fields.Many2one(
+ "res.users", string="Marked By", default=lambda self: self.env.user, readonly=True,
+ )
+
+ _student_date_session_uniq = models.Constraint(
+ "unique(student_id, date, session)",
+ "Attendance for this student on this date and session has already been recorded.",
+ )
+
+ @api.depends("student_id.name", "date", "session")
+ def _compute_display_name(self):
+ for attendance in self:
+ attendance.display_name = "%s - %s (%s)" % (
+ attendance.student_id.name or "?", attendance.date, attendance.session,
+ )
diff --git a/addons/mc_education_attendance/security/ir.model.access.csv b/addons/mc_education_attendance/security/ir.model.access.csv
new file mode 100644
index 0000000..bcfff9e
--- /dev/null
+++ b/addons/mc_education_attendance/security/ir.model.access.csv
@@ -0,0 +1,10 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_mc_attendance_administrator,mc.attendance.administrator,model_mc_attendance,mc_education_base.group_school_administrator,1,1,1,1
+access_mc_attendance_staff,mc.attendance.staff,model_mc_attendance,mc_education_base.group_school_staff,1,1,1,0
+access_mc_attendance_teacher,mc.attendance.teacher,model_mc_attendance,mc_education_base.group_teacher,1,1,1,0
+access_mc_attendance_bulk_wizard_administrator,mc.attendance.bulk.wizard.administrator,model_mc_attendance_bulk_wizard,mc_education_base.group_school_administrator,1,1,1,1
+access_mc_attendance_bulk_wizard_staff,mc.attendance.bulk.wizard.staff,model_mc_attendance_bulk_wizard,mc_education_base.group_school_staff,1,1,1,1
+access_mc_attendance_bulk_wizard_teacher,mc.attendance.bulk.wizard.teacher,model_mc_attendance_bulk_wizard,mc_education_base.group_teacher,1,1,1,1
+access_mc_attendance_bulk_wizard_line_administrator,mc.attendance.bulk.wizard.line.administrator,model_mc_attendance_bulk_wizard_line,mc_education_base.group_school_administrator,1,1,1,1
+access_mc_attendance_bulk_wizard_line_staff,mc.attendance.bulk.wizard.line.staff,model_mc_attendance_bulk_wizard_line,mc_education_base.group_school_staff,1,1,1,1
+access_mc_attendance_bulk_wizard_line_teacher,mc.attendance.bulk.wizard.line.teacher,model_mc_attendance_bulk_wizard_line,mc_education_base.group_teacher,1,1,1,1
diff --git a/addons/mc_education_attendance/security/mc_education_attendance_security.xml b/addons/mc_education_attendance/security/mc_education_attendance_security.xml
new file mode 100644
index 0000000..170191a
--- /dev/null
+++ b/addons/mc_education_attendance/security/mc_education_attendance_security.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ Attendance: teacher sees only their own batch
+
+ [('batch_id.class_teacher_id.user_id', '=', user.id)]
+
+
+
diff --git a/addons/mc_education_attendance/tests/__init__.py b/addons/mc_education_attendance/tests/__init__.py
new file mode 100644
index 0000000..610adcf
--- /dev/null
+++ b/addons/mc_education_attendance/tests/__init__.py
@@ -0,0 +1,2 @@
+from . import test_attendance
+from . import test_access
diff --git a/addons/mc_education_attendance/tests/test_access.py b/addons/mc_education_attendance/tests/test_access.py
new file mode 100644
index 0000000..86e9966
--- /dev/null
+++ b/addons/mc_education_attendance/tests/test_access.py
@@ -0,0 +1,90 @@
+from odoo.exceptions import AccessError
+from odoo.tests.common import TransactionCase
+
+
+class TestAttendanceAccess(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.year = cls.env["mc.academic.year"].create({
+ "name": "TEST-ATT-ACC-2026-27",
+ "date_start": "2026-06-01", "date_end": "2027-04-30",
+ })
+ cls.program = cls.env["mc.program"].create({
+ "name": "TEST ATT ACC Program", "code": "TEST-ATT-ACC-P1", "sequence_no": 1,
+ "display_label": "Test Grade",
+ })
+
+ teacher_group = cls.env.ref("mc_education_base.group_teacher").id
+ cls.user_a = cls.env["res.users"].create({
+ "name": "Teacher A", "login": "test_att_teacher_a",
+ "email": "test_att_teacher_a@example.com",
+ "group_ids": [(6, 0, [teacher_group])],
+ })
+ cls.employee_a = cls.env["hr.employee"].create({
+ "name": "Teacher A", "user_id": cls.user_a.id,
+ })
+ cls.user_b = cls.env["res.users"].create({
+ "name": "Teacher B", "login": "test_att_teacher_b",
+ "email": "test_att_teacher_b@example.com",
+ "group_ids": [(6, 0, [teacher_group])],
+ })
+ cls.employee_b = cls.env["hr.employee"].create({
+ "name": "Teacher B", "user_id": cls.user_b.id,
+ })
+
+ cls.batch_a = cls.env["mc.batch"].create({
+ "name": "TEST ATT ACC Batch A", "program_id": cls.program.id,
+ "year_id": cls.year.id, "class_teacher_id": cls.employee_a.id,
+ })
+ cls.batch_b = cls.env["mc.batch"].create({
+ "name": "TEST ATT ACC Batch B", "program_id": cls.program.id,
+ "year_id": cls.year.id, "class_teacher_id": cls.employee_b.id,
+ })
+
+ partner = cls.env["res.partner"].create({"name": "ATT ACC Student"})
+ cls.student = cls.env["mc.student"].create({
+ "partner_id": partner.id, "name": "ATT ACC Student",
+ })
+ cls.env["mc.enrollment"].create({
+ "student_id": cls.student.id, "program_id": cls.program.id,
+ "batch_id": cls.batch_a.id, "year_id": cls.year.id, "state": "active",
+ })
+
+ def test_teacher_can_mark_own_batch(self):
+ attendance = self.env["mc.attendance"].with_user(self.user_a).create({
+ "student_id": self.student.id, "batch_id": self.batch_a.id,
+ "date": "2026-09-01", "state": "present",
+ })
+ self.assertTrue(attendance)
+ attendance.with_user(self.user_a).write({"state": "absent"})
+ self.assertEqual(attendance.state, "absent")
+
+ def test_teacher_cannot_mark_another_teachers_batch(self):
+ with self.assertRaises(AccessError):
+ self.env["mc.attendance"].with_user(self.user_b).create({
+ "student_id": self.student.id, "batch_id": self.batch_a.id,
+ "date": "2026-09-01", "state": "present",
+ })
+
+ def test_teacher_cannot_read_another_teachers_batch_attendance(self):
+ # Created as admin (no record rule applies), then confirm teacher B
+ # cannot read it even by browsing the known record id directly -
+ # this is the "cannot open another teacher's batch by editing the
+ # URL" requirement from shared/DEMO_SCRIPT.md Scene 4.
+ attendance = self.env["mc.attendance"].create({
+ "student_id": self.student.id, "batch_id": self.batch_a.id,
+ "date": "2026-09-02", "state": "present",
+ })
+ with self.assertRaises(AccessError):
+ attendance.with_user(self.user_b).read(["state"])
+
+ def test_administrator_is_not_restricted_by_the_rule(self):
+ # The record rule is scoped to group_teacher only - Administrator
+ # (superuser in this test env) must remain unrestricted.
+ attendance = self.env["mc.attendance"].create({
+ "student_id": self.student.id, "batch_id": self.batch_b.id,
+ "date": "2026-09-03", "state": "present",
+ })
+ self.assertTrue(attendance)
diff --git a/addons/mc_education_attendance/tests/test_attendance.py b/addons/mc_education_attendance/tests/test_attendance.py
new file mode 100644
index 0000000..f7ea207
--- /dev/null
+++ b/addons/mc_education_attendance/tests/test_attendance.py
@@ -0,0 +1,115 @@
+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")
diff --git a/addons/mc_education_attendance/views/mc_attendance_bulk_wizard_views.xml b/addons/mc_education_attendance/views/mc_attendance_bulk_wizard_views.xml
new file mode 100644
index 0000000..5aeaf9b
--- /dev/null
+++ b/addons/mc_education_attendance/views/mc_attendance_bulk_wizard_views.xml
@@ -0,0 +1,34 @@
+
+
+
+ mc.attendance.bulk.wizard.form
+ mc.attendance.bulk.wizard
+
+
+
+
+
+
+ Mark Attendance
+ mc.attendance.bulk.wizard
+ form
+ new
+
+
diff --git a/addons/mc_education_attendance/views/mc_attendance_views.xml b/addons/mc_education_attendance/views/mc_attendance_views.xml
new file mode 100644
index 0000000..422a6a0
--- /dev/null
+++ b/addons/mc_education_attendance/views/mc_attendance_views.xml
@@ -0,0 +1,41 @@
+
+
+
+ mc.attendance.list
+ mc.attendance
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mc.attendance.search
+ mc.attendance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Attendance Records
+ mc.attendance
+ list
+
+
diff --git a/addons/mc_education_attendance/views/mc_education_attendance_menus.xml b/addons/mc_education_attendance/views/mc_education_attendance_menus.xml
new file mode 100644
index 0000000..6fafb3a
--- /dev/null
+++ b/addons/mc_education_attendance/views/mc_education_attendance_menus.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/addons/mc_education_attendance/wizards/__init__.py b/addons/mc_education_attendance/wizards/__init__.py
new file mode 100644
index 0000000..7148cbb
--- /dev/null
+++ b/addons/mc_education_attendance/wizards/__init__.py
@@ -0,0 +1 @@
+from . import mc_attendance_bulk_wizard
diff --git a/addons/mc_education_attendance/wizards/mc_attendance_bulk_wizard.py b/addons/mc_education_attendance/wizards/mc_attendance_bulk_wizard.py
new file mode 100644
index 0000000..7006684
--- /dev/null
+++ b/addons/mc_education_attendance/wizards/mc_attendance_bulk_wizard.py
@@ -0,0 +1,79 @@
+from odoo import api, fields, models
+
+
+class McAttendanceBulkWizard(models.TransientModel):
+ _name = "mc.attendance.bulk.wizard"
+ _description = "Mark Attendance"
+
+ batch_id = fields.Many2one("mc.batch", string="Batch", required=True)
+ date = fields.Date(string="Date", required=True, default=fields.Date.context_today)
+ session = fields.Char(string="Session", required=True, default="Daily")
+ subject_id = fields.Many2one("mc.subject", string="Subject")
+ line_ids = fields.One2many("mc.attendance.bulk.wizard.line", "wizard_id", string="Roster")
+
+ @api.onchange("batch_id", "date", "session")
+ def _onchange_load_roster(self):
+ if not (self.batch_id and self.date and self.session):
+ return
+ enrollments = self.env["mc.enrollment"].search([
+ ("batch_id", "=", self.batch_id.id), ("state", "=", "active"),
+ ])
+ # Re-opening the wizard for a batch/date/session already marked
+ # pre-fills from what is already there, so resubmitting edits the
+ # same rows instead of a teacher never seeing their earlier taps.
+ existing = self.env["mc.attendance"].search([
+ ("batch_id", "=", self.batch_id.id),
+ ("date", "=", self.date),
+ ("session", "=", self.session),
+ ])
+ existing_state_by_student = {a.student_id.id: a.state for a in existing}
+ self.line_ids = [
+ (0, 0, {
+ "student_id": enrollment.student_id.id,
+ "state": existing_state_by_student.get(enrollment.student_id.id, "present"),
+ })
+ for enrollment in enrollments.sorted(key=lambda e: e.student_id.name or "")
+ ]
+
+ def action_submit(self):
+ self.ensure_one()
+ Attendance = self.env["mc.attendance"]
+ for line in self.line_ids:
+ vals = {
+ "student_id": line.student_id.id,
+ "batch_id": self.batch_id.id,
+ "date": self.date,
+ "session": self.session,
+ "subject_id": self.subject_id.id if self.subject_id else False,
+ "state": line.state,
+ }
+ # Idempotent by design: same student + date + session updates
+ # the existing row rather than creating a duplicate, whether
+ # this is the first submission or the fifth correction.
+ existing = Attendance.search([
+ ("student_id", "=", line.student_id.id),
+ ("date", "=", self.date),
+ ("session", "=", self.session),
+ ], limit=1)
+ if existing:
+ existing.write(vals)
+ else:
+ Attendance.create(vals)
+ return {"type": "ir.actions.act_window_close"}
+
+
+class McAttendanceBulkWizardLine(models.TransientModel):
+ _name = "mc.attendance.bulk.wizard.line"
+ _description = "Mark Attendance Roster Line"
+
+ wizard_id = fields.Many2one("mc.attendance.bulk.wizard", string="Wizard", required=True, ondelete="cascade")
+ student_id = fields.Many2one("mc.student", string="Student", required=True)
+ state = fields.Selection(
+ [
+ ("present", "Present"),
+ ("absent", "Absent"),
+ ("late", "Late"),
+ ("on_leave", "On Leave"),
+ ],
+ string="Status", required=True, default="present",
+ )