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>
66 lines
2.4 KiB
Python
66 lines
2.4 KiB
Python
from odoo import api, fields, models
|
|
|
|
WEEKDAYS = [
|
|
('mon', 'Monday'), ('tue', 'Tuesday'), ('wed', 'Wednesday'), ('thu', 'Thursday'),
|
|
('fri', 'Friday'), ('sat', 'Saturday'), ('sun', 'Sunday'),
|
|
]
|
|
|
|
|
|
class CommunitySchoolClass(models.Model):
|
|
_name = 'community.school.class'
|
|
_description = 'School Class'
|
|
_order = 'id desc'
|
|
|
|
name = fields.Char(compute='_compute_name', store=True)
|
|
level_id = fields.Many2one('community.school.level', required=True)
|
|
term_id = fields.Many2one('community.school.term', required=True)
|
|
teacher_id = fields.Many2one('res.partner', domain=[('is_teacher', '=', True)])
|
|
max_students = fields.Integer(default=20)
|
|
enrolled_count = fields.Integer(compute='_compute_enrolled_count', store=True)
|
|
weekday = fields.Selection(WEEKDAYS)
|
|
start_time = fields.Float()
|
|
end_time = fields.Float()
|
|
location = fields.Char()
|
|
slide_channel_id = fields.Many2one('slide.channel', readonly=True, copy=False)
|
|
enrollment_ids = fields.One2many('community.school.enrollment', 'class_id')
|
|
state = fields.Selection(
|
|
[('draft', 'Draft'), ('open', 'Open'), ('closed', 'Closed')],
|
|
default='draft', required=True,
|
|
)
|
|
|
|
@api.depends('level_id.name', 'term_id.name')
|
|
def _compute_name(self):
|
|
for record in self:
|
|
record.name = f"{record.level_id.name or '?'} - {record.term_id.name or '?'}"
|
|
|
|
@api.depends('enrollment_ids.state')
|
|
def _compute_enrolled_count(self):
|
|
if not self:
|
|
return
|
|
counts = dict(self.env['community.school.enrollment']._read_group(
|
|
[('class_id', 'in', self.ids), ('state', '=', 'enrolled')],
|
|
groupby=['class_id'], aggregates=['__count'],
|
|
))
|
|
for record in self:
|
|
record.enrolled_count = counts.get(record, 0)
|
|
|
|
def _get_or_create_slide_channel(self):
|
|
self.ensure_one()
|
|
if self.slide_channel_id:
|
|
return self.slide_channel_id
|
|
channel = self.env['slide.channel'].create({
|
|
'name': self.name,
|
|
'channel_type': 'training',
|
|
'visibility': 'members',
|
|
'enroll': 'invite',
|
|
})
|
|
self.slide_channel_id = channel.id
|
|
return channel
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
classes = super().create(vals_list)
|
|
for record in classes:
|
|
record._get_or_create_slide_channel()
|
|
return classes
|