Adds the multi-step website registration at /school/register (parent -> student -> class), carrying state across steps in the request session (auth='public' - a family with no account yet can register). Finalizing creates/finds the parent partner by email, creates the child partner + student, and creates the enrollment as 'enrolled' or 'waitlist' depending on whether the chosen class.fee_product_id/max_students still has room - generating a draft fee invoice when the class has a fee (a new class.fee/fee_product_id, following the same auto-created-product pattern as community_membership's tiers). LMS glue: enrollment create/write now auto-enrols the student's partner into the class's slide.channel via _action_add_members() when state becomes 'enrolled', and deactivates the slide.channel.partner membership on withdrawal. An hourly cron (_cron_promote_waitlist) fills freed seats from the waitlist in enrollment-date order and emails the parent. Adds scripts/migrate_classroom.py: a standalone, dependency-free (stdlib only) JSON-RPC script that reads a title,url CSV and creates slide.slide records in a target channel - YouTube links become published video slides (source_type='external' lets Odoo's own compute fields resolve youtube_id automatically), everything else becomes an unpublished document slide flagged for manual re-upload/review, since the script can't read Google Drive content itself. Idempotent by (channel_id, title); --dry-run and --force supported. Verified against a live Odoo 19 + Postgres 16 container: 14/14 automated tests pass, plus full manual live runs - walked all three registration steps over real HTTP (with CSRF tokens) and confirmed the resulting enrollment and draft invoice; ran the migration script twice against a real channel and confirmed the second run skipped both already-created slides, with the YouTube slide's youtube_id correctly auto-derived. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
131 lines
5.2 KiB
Python
131 lines
5.2 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
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
enrollments = super().create(vals_list)
|
|
for enrollment in enrollments:
|
|
if enrollment.state == 'enrolled':
|
|
enrollment._lms_enrol()
|
|
return enrollments
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
if 'state' in vals:
|
|
for enrollment in self:
|
|
if enrollment.state == 'enrolled':
|
|
enrollment._lms_enrol()
|
|
elif enrollment.state == 'withdrawn':
|
|
enrollment._lms_unenrol()
|
|
return res
|
|
|
|
def _lms_enrol(self):
|
|
self.ensure_one()
|
|
channel = self.class_id.slide_channel_id
|
|
if channel and self.student_id.partner_id:
|
|
channel.sudo()._action_add_members(self.student_id.partner_id, member_status='joined')
|
|
|
|
def _lms_unenrol(self):
|
|
self.ensure_one()
|
|
channel = self.class_id.slide_channel_id
|
|
if not channel:
|
|
return
|
|
membership = self.env['slide.channel.partner'].sudo().search([
|
|
('channel_id', '=', channel.id),
|
|
('partner_id', '=', self.student_id.partner_id.id),
|
|
])
|
|
membership.write({'active': False})
|
|
|
|
def _create_fee_invoice(self):
|
|
self.ensure_one()
|
|
product = self.class_id.fee_product_id or self.class_id._get_or_create_fee_product()
|
|
if not self.class_id.fee:
|
|
return self.env['account.move']
|
|
invoice = self.env['account.move'].create({
|
|
'move_type': 'out_invoice',
|
|
'partner_id': self.student_id.parent_partner_id.id,
|
|
'invoice_origin': 'School Registration',
|
|
'invoice_line_ids': [(0, 0, {
|
|
'product_id': product.id,
|
|
'quantity': 1,
|
|
'price_unit': self.class_id.fee,
|
|
})],
|
|
})
|
|
self.write({'invoice_id': invoice.id, 'payment_state': 'invoiced'})
|
|
return invoice
|
|
|
|
@api.model
|
|
def _cron_promote_waitlist(self):
|
|
"""Hourly: promote the earliest waitlisted enrollment(s) into any newly-freed seats."""
|
|
classes = self.env['community.school.class'].search([])
|
|
for klass in classes:
|
|
free_seats = klass.max_students - klass.enrolled_count
|
|
if free_seats <= 0:
|
|
continue
|
|
waitlisted = self.search([
|
|
('class_id', '=', klass.id), ('state', '=', 'waitlist'),
|
|
], order='enrollment_date asc, id asc', limit=free_seats)
|
|
for enrollment in waitlisted:
|
|
enrollment.write({'state': 'enrolled'})
|
|
enrollment._send_seat_available_notice()
|
|
return True
|
|
|
|
def _send_seat_available_notice(self):
|
|
self.ensure_one()
|
|
template = self.env.ref('community_school.mail_template_seat_available', raise_if_not_found=False)
|
|
if template:
|
|
template.send_mail(self.id, force_send=False)
|