Session 3-A - core models: community.school.term, .level (admin-defined, so the same module fits a Grade 1-12 school or a Beginner-Advanced language school with no code change), .class (auto-creates a linked slide.channel for LMS glue), .student, .enrollment, and .attendance, plus is_teacher on res.partner. Class enrolled_count and enrollment attendance_rate are stored/non-stored computes driven by the enrollment/attendance one2many chains. Session 3-B - teacher attendance + at-risk reporting: a portal page at /school/attendance (auth='user', scoped to classes where teacher_id matches the logged-in user's partner - works whether the teacher is an internal or portal user) with a roster and batch save, built as a v19 Interaction (same pattern as event_qr_ticketing's check-in page). Marking a student absent queues a configurable notice to the parent partner. Enrollment.is_at_risk flags students below a configurable attendance threshold (School settings page, same <app>/<block>/<setting> pattern as community_membership), plus an admin pivot attendance report and an At-Risk Students list. Verified against a live Odoo 19 + Postgres 16 container: 14/14 automated tests pass (class/slide-channel creation, enrolled_count tracking incl. withdrawal, attendance_rate and at-risk computation, unique enrollment+date constraint, absence email queuing), plus a full manual live run - created a term/level/class/student/enrollment via JSON-RPC, loaded the actual /school/attendance page as the teacher, POSTed a batch save marking the student absent, and confirmed both the attendance record and the queued "Absence notice" mail.mail record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
56 lines
2.3 KiB
Python
56 lines
2.3 KiB
Python
from odoo import api, fields, models
|
|
|
|
|
|
class CommunitySchoolEnrollment(models.Model):
|
|
_name = 'community.school.enrollment'
|
|
_description = 'School Enrollment'
|
|
_order = 'id desc'
|
|
|
|
student_id = fields.Many2one('community.school.student', required=True, ondelete='cascade')
|
|
class_id = fields.Many2one('community.school.class', required=True, ondelete='cascade')
|
|
term_id = fields.Many2one(related='class_id.term_id', store=True)
|
|
enrollment_date = fields.Date(default=fields.Date.context_today)
|
|
state = fields.Selection(
|
|
[
|
|
('waitlist', 'Waitlisted'),
|
|
('enrolled', 'Enrolled'),
|
|
('completed', 'Completed'),
|
|
('withdrawn', 'Withdrawn'),
|
|
],
|
|
default='enrolled', required=True,
|
|
)
|
|
payment_state = fields.Selection(
|
|
[('not_invoiced', 'Not Invoiced'), ('invoiced', 'Invoiced'), ('paid', 'Paid')],
|
|
default='not_invoiced', required=True,
|
|
)
|
|
invoice_id = fields.Many2one('account.move', readonly=True, copy=False)
|
|
attendance_ids = fields.One2many('community.school.attendance', 'enrollment_id')
|
|
attendance_rate = fields.Float(compute='_compute_attendance_rate')
|
|
|
|
is_at_risk = fields.Boolean(compute='_compute_is_at_risk')
|
|
|
|
@api.depends('attendance_ids.state')
|
|
def _compute_attendance_rate(self):
|
|
for enrollment in self:
|
|
records = enrollment.attendance_ids
|
|
total = len(records)
|
|
if not total:
|
|
enrollment.attendance_rate = 0.0
|
|
continue
|
|
present = len(records.filtered(lambda a: a.state in ('present', 'late')))
|
|
enrollment.attendance_rate = (present / total) * 100.0
|
|
|
|
@api.depends('attendance_rate', 'attendance_ids')
|
|
def _compute_is_at_risk(self):
|
|
threshold = int(self.env['ir.config_parameter'].sudo().get_param(
|
|
'community_school.at_risk_attendance_threshold', 70
|
|
))
|
|
for enrollment in self:
|
|
enrollment.is_at_risk = bool(enrollment.attendance_ids) and enrollment.attendance_rate < threshold
|
|
|
|
def action_enroll_from_waitlist(self):
|
|
for enrollment in self:
|
|
if enrollment.state == 'waitlist' and enrollment.class_id.enrolled_count < enrollment.class_id.max_students:
|
|
enrollment.state = 'enrolled'
|
|
return True
|