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:
metatroncubeswdev 2026-09-11 12:49:04 -04:00
parent d6f891838b
commit e5aba291f1
15 changed files with 569 additions and 0 deletions

View File

@ -0,0 +1,2 @@
from . import models
from . import wizards

View 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,
}

View 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>

View File

@ -0,0 +1 @@
from . import mc_attendance

View 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,
)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mc_attendance_administrator mc.attendance.administrator model_mc_attendance mc_education_base.group_school_administrator 1 1 1 1
3 access_mc_attendance_staff mc.attendance.staff model_mc_attendance mc_education_base.group_school_staff 1 1 1 0
4 access_mc_attendance_teacher mc.attendance.teacher model_mc_attendance mc_education_base.group_teacher 1 1 1 0
5 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
6 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
7 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
8 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
9 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
10 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

View File

@ -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>

View File

@ -0,0 +1,2 @@
from . import test_attendance
from . import test_access

View 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)

View 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")

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -0,0 +1 @@
from . import mc_attendance_bulk_wizard

View File

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