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", + )