O4: mc_education_attendance - mobile-first bulk marking, own batches only
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>
This commit is contained in:
parent
d6f891838b
commit
e5aba291f1
2
addons/mc_education_attendance/__init__.py
Normal file
2
addons/mc_education_attendance/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import wizards
|
||||||
21
addons/mc_education_attendance/__manifest__.py
Normal file
21
addons/mc_education_attendance/__manifest__.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
104
addons/mc_education_attendance/demo/mc_attendance_demo.xml
Normal file
104
addons/mc_education_attendance/demo/mc_attendance_demo.xml
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- History through the start of this month, leaving today unmarked
|
||||||
|
so Demo Scene 4 (a teacher taking attendance live) has something
|
||||||
|
real to do, and Scene 1's dashboard / Scene 5's month view have
|
||||||
|
a real pattern to show instead of an empty state. -->
|
||||||
|
<record id="demo_attendance_aditya_09_01" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-01</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_aditya_09_02" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-02</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_aditya_09_03" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-03</field>
|
||||||
|
<field name="state">absent</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_aditya_09_04" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-04</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_aditya_09_07" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-07</field>
|
||||||
|
<field name="state">late</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_aditya_09_08" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-08</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_aditya_09_09" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-09</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_aditya_09_10" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_aditya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade8_a"/>
|
||||||
|
<field name="date">2026-09-10</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="demo_attendance_ananya_09_01" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-01</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_ananya_09_02" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-02</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_ananya_09_03" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-03</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_ananya_09_04" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-04</field>
|
||||||
|
<field name="state">on_leave</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_ananya_09_07" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-07</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_ananya_09_08" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-08</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_ananya_09_09" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-09</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
<record id="demo_attendance_ananya_09_10" model="mc.attendance">
|
||||||
|
<field name="student_id" ref="mc_education_base.demo_student_ananya_krishnan"/>
|
||||||
|
<field name="batch_id" ref="mc_education_base.demo_batch_grade5_b"/>
|
||||||
|
<field name="date">2026-09-10</field>
|
||||||
|
<field name="state">present</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
1
addons/mc_education_attendance/models/__init__.py
Normal file
1
addons/mc_education_attendance/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import mc_attendance
|
||||||
42
addons/mc_education_attendance/models/mc_attendance.py
Normal file
42
addons/mc_education_attendance/models/mc_attendance.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
10
addons/mc_education_attendance/security/ir.model.access.csv
Normal file
10
addons/mc_education_attendance/security/ir.model.access.csv
Normal file
@ -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
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- CLAUDE.md sec 3 standing rule + O4 spec: "Teachers may only mark
|
||||||
|
their own batches - record rule, not a UI check." This rule is
|
||||||
|
scoped to group_teacher only (via the groups field) - it does not
|
||||||
|
apply to Administrator/Staff/Accountant, who are unrestricted by
|
||||||
|
it and keep whatever their ir.model.access.csv rows grant. -->
|
||||||
|
<record id="rule_mc_attendance_teacher_own_batch" model="ir.rule">
|
||||||
|
<field name="name">Attendance: teacher sees only their own batch</field>
|
||||||
|
<field name="model_id" ref="model_mc_attendance"/>
|
||||||
|
<field name="domain_force">[('batch_id.class_teacher_id.user_id', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('mc_education_base.group_teacher'))]"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
2
addons/mc_education_attendance/tests/__init__.py
Normal file
2
addons/mc_education_attendance/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import test_attendance
|
||||||
|
from . import test_access
|
||||||
90
addons/mc_education_attendance/tests/test_access.py
Normal file
90
addons/mc_education_attendance/tests/test_access.py
Normal file
@ -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)
|
||||||
115
addons/mc_education_attendance/tests/test_attendance.py
Normal file
115
addons/mc_education_attendance/tests/test_attendance.py
Normal file
@ -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")
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_mc_attendance_bulk_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">mc.attendance.bulk.wizard.form</field>
|
||||||
|
<field name="model">mc.attendance.bulk.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Mark Attendance">
|
||||||
|
<group>
|
||||||
|
<field name="batch_id"/>
|
||||||
|
<field name="date"/>
|
||||||
|
<field name="session"/>
|
||||||
|
<field name="subject_id"/>
|
||||||
|
</group>
|
||||||
|
<field name="line_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="student_id" readonly="1"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
<footer>
|
||||||
|
<button name="action_submit" string="Submit" type="object" class="btn-primary"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_mc_attendance_bulk_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Mark Attendance</field>
|
||||||
|
<field name="res_model">mc.attendance.bulk.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
41
addons/mc_education_attendance/views/mc_attendance_views.xml
Normal file
41
addons/mc_education_attendance/views/mc_attendance_views.xml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_mc_attendance_list" model="ir.ui.view">
|
||||||
|
<field name="name">mc.attendance.list</field>
|
||||||
|
<field name="model">mc.attendance</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Attendance">
|
||||||
|
<field name="date"/>
|
||||||
|
<field name="batch_id"/>
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="session"/>
|
||||||
|
<field name="state" widget="badge"/>
|
||||||
|
<field name="marked_by"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_mc_attendance_search" model="ir.ui.view">
|
||||||
|
<field name="name">mc.attendance.search</field>
|
||||||
|
<field name="model">mc.attendance</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Attendance">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="batch_id"/>
|
||||||
|
<field name="date"/>
|
||||||
|
<filter string="Absent" name="filter_absent" domain="[('state', '=', 'absent')]"/>
|
||||||
|
<filter string="On Leave" name="filter_on_leave" domain="[('state', '=', 'on_leave')]"/>
|
||||||
|
<group>
|
||||||
|
<filter string="Batch" name="group_batch" context="{'group_by': 'batch_id'}"/>
|
||||||
|
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_mc_attendance" model="ir.actions.act_window">
|
||||||
|
<field name="name">Attendance Records</field>
|
||||||
|
<field name="res_model">mc.attendance</field>
|
||||||
|
<field name="view_mode">list</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_school_attendance" name="Attendance"
|
||||||
|
parent="mc_education_base.menu_school_root" sequence="12"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_mc_attendance_mark" name="Mark Attendance"
|
||||||
|
parent="menu_school_attendance"
|
||||||
|
action="action_mc_attendance_bulk_wizard" sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_mc_attendance_records" name="Attendance Records"
|
||||||
|
parent="menu_school_attendance"
|
||||||
|
action="action_mc_attendance" sequence="20"/>
|
||||||
|
</odoo>
|
||||||
1
addons/mc_education_attendance/wizards/__init__.py
Normal file
1
addons/mc_education_attendance/wizards/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import mc_attendance_bulk_wizard
|
||||||
@ -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",
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user