diff --git a/addons/community_school/__manifest__.py b/addons/community_school/__manifest__.py index eb8d1f5..67f35e9 100644 --- a/addons/community_school/__manifest__.py +++ b/addons/community_school/__manifest__.py @@ -33,10 +33,12 @@ with no code change. 'security/school_security.xml', 'security/ir.model.access.csv', 'data/mail_templates.xml', + 'data/ir_cron.xml', 'views/school_views.xml', 'views/school_menus.xml', 'views/res_config_settings_views.xml', 'views/portal_attendance_templates.xml', + 'views/registration_templates.xml', ], 'demo': [], 'assets': { diff --git a/addons/community_school/controllers/__init__.py b/addons/community_school/controllers/__init__.py index 72ac9f0..7419fd2 100644 --- a/addons/community_school/controllers/__init__.py +++ b/addons/community_school/controllers/__init__.py @@ -1 +1,2 @@ from . import attendance +from . import registration diff --git a/addons/community_school/controllers/registration.py b/addons/community_school/controllers/registration.py new file mode 100644 index 0000000..7906328 --- /dev/null +++ b/addons/community_school/controllers/registration.py @@ -0,0 +1,86 @@ +from odoo import http +from odoo.http import request + +SESSION_PARENT_KEY = 'school_reg_parent' +SESSION_STUDENT_KEY = 'school_reg_student' + + +class SchoolRegistrationController(http.Controller): + + @http.route(['/school/register'], type='http', auth='public', website=True) + def register_parent(self, **kwargs): + if http.request.httprequest.method == 'POST': + request.session[SESSION_PARENT_KEY] = { + 'name': kwargs.get('name', '').strip(), + 'email': kwargs.get('email', '').strip(), + 'phone': kwargs.get('phone', '').strip(), + } + return request.redirect('/school/register/student') + return request.render('community_school.registration_step_parent', {}) + + @http.route(['/school/register/student'], type='http', auth='public', website=True) + def register_student(self, **kwargs): + if not request.session.get(SESSION_PARENT_KEY): + return request.redirect('/school/register') + if request.httprequest.method == 'POST': + request.session[SESSION_STUDENT_KEY] = { + 'name': kwargs.get('name', '').strip(), + 'date_of_birth': kwargs.get('date_of_birth') or False, + 'health_notes': kwargs.get('health_notes', '').strip(), + 'emergency_contact_name': kwargs.get('emergency_contact_name', '').strip(), + 'emergency_contact_phone': kwargs.get('emergency_contact_phone', '').strip(), + } + return request.redirect('/school/register/class') + return request.render('community_school.registration_step_student', {}) + + @http.route(['/school/register/class'], type='http', auth='public', website=True) + def register_class(self, **kwargs): + if not request.session.get(SESSION_PARENT_KEY) or not request.session.get(SESSION_STUDENT_KEY): + return request.redirect('/school/register') + + if request.httprequest.method == 'POST': + return self._finalize_registration(int(kwargs.get('class_id', 0))) + + classes = request.env['community.school.class'].sudo().search([('state', '=', 'open')]) + return request.render('community_school.registration_step_class', {'classes': classes}) + + def _finalize_registration(self, class_id): + Partner = request.env['res.partner'].sudo() + parent_data = request.session[SESSION_PARENT_KEY] + student_data = request.session[SESSION_STUDENT_KEY] + + parent = Partner.search([('email', '=', parent_data['email'])], limit=1) if parent_data['email'] else Partner + if not parent: + parent = Partner.create({ + 'name': parent_data['name'], + 'email': parent_data['email'], + 'phone': parent_data['phone'], + }) + + child_partner = Partner.create({'name': student_data['name']}) + student = request.env['community.school.student'].sudo().create({ + 'partner_id': child_partner.id, + 'parent_partner_id': parent.id, + 'date_of_birth': student_data['date_of_birth'], + 'health_notes': student_data['health_notes'], + 'emergency_contact_name': student_data['emergency_contact_name'], + 'emergency_contact_phone': student_data['emergency_contact_phone'], + }) + + klass = request.env['community.school.class'].sudo().browse(class_id) + state = 'enrolled' if klass.enrolled_count < klass.max_students else 'waitlist' + enrollment = request.env['community.school.enrollment'].sudo().create({ + 'student_id': student.id, + 'class_id': klass.id, + 'state': state, + }) + if state == 'enrolled' and klass.fee: + enrollment._create_fee_invoice() + + request.session.pop(SESSION_PARENT_KEY, None) + request.session.pop(SESSION_STUDENT_KEY, None) + + return request.render('community_school.registration_done', { + 'enrollment': enrollment, + 'waitlisted': state == 'waitlist', + }) diff --git a/addons/community_school/data/ir_cron.xml b/addons/community_school/data/ir_cron.xml new file mode 100644 index 0000000..05b0700 --- /dev/null +++ b/addons/community_school/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + School: Promote Waitlist + + code + model._cron_promote_waitlist() + 1 + hours + + + + diff --git a/addons/community_school/data/mail_templates.xml b/addons/community_school/data/mail_templates.xml index f1dbd1f..aad7ad4 100644 --- a/addons/community_school/data/mail_templates.xml +++ b/addons/community_school/data/mail_templates.xml @@ -18,6 +18,24 @@ on .

Notes:

+ + + + + School: Waitlist Seat Available + + A seat opened up in {{ object.class_id.name }} + {{ object.student_id.parent_partner_id.id }} + + +
+

Dear Parent,

+

+ Good news - a seat has opened up in + + and has been moved from the + waitlist to enrolled. +

diff --git a/addons/community_school/models/school_class.py b/addons/community_school/models/school_class.py index 8d896d1..4e568df 100644 --- a/addons/community_school/models/school_class.py +++ b/addons/community_school/models/school_class.py @@ -16,6 +16,9 @@ class CommunitySchoolClass(models.Model): 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) + fee = fields.Monetary(currency_field='currency_id') + currency_id = fields.Many2one('res.currency', default=lambda self: self.env.company.currency_id) + fee_product_id = fields.Many2one('product.product', readonly=True, copy=False) enrolled_count = fields.Integer(compute='_compute_enrolled_count', store=True) weekday = fields.Selection(WEEKDAYS) start_time = fields.Float() @@ -57,6 +60,21 @@ class CommunitySchoolClass(models.Model): self.slide_channel_id = channel.id return channel + def _get_or_create_fee_product(self): + self.ensure_one() + if self.fee_product_id: + return self.fee_product_id + product = self.env['product.product'].create({ + 'name': f"School Fee - {self.name}", + 'list_price': self.fee, + 'type': 'service', + 'sale_ok': True, + 'purchase_ok': False, + 'invoice_policy': 'order', + }) + self.fee_product_id = product.id + return product + @api.model_create_multi def create(self, vals_list): classes = super().create(vals_list) diff --git a/addons/community_school/models/school_enrollment.py b/addons/community_school/models/school_enrollment.py index a7d09d0..6649afd 100644 --- a/addons/community_school/models/school_enrollment.py +++ b/addons/community_school/models/school_enrollment.py @@ -53,3 +53,78 @@ class CommunitySchoolEnrollment(models.Model): 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) diff --git a/addons/community_school/tests/__init__.py b/addons/community_school/tests/__init__.py index 84926b2..dcccc65 100644 --- a/addons/community_school/tests/__init__.py +++ b/addons/community_school/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_school from . import test_school_attendance +from . import test_school_registration diff --git a/addons/community_school/tests/test_school_registration.py b/addons/community_school/tests/test_school_registration.py new file mode 100644 index 0000000..d74ff4b --- /dev/null +++ b/addons/community_school/tests/test_school_registration.py @@ -0,0 +1,84 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSchoolRegistration(TransactionCase): + + def setUp(self): + super().setUp() + self.term = self.env['community.school.term'].create({ + 'name': 'Reg Term', 'start_date': '2030-09-01', 'end_date': '2030-12-15', + }) + self.level = self.env['community.school.level'].create({'name': 'Beginner', 'code': 'REG-BEG'}) + self.klass = self.env['community.school.class'].create({ + 'level_id': self.level.id, 'term_id': self.term.id, 'max_students': 1, 'fee': 100.0, + }) + + def _make_student(self, name): + parent = self.env['res.partner'].create({'name': f'{name} Parent', 'email': f'{name.lower()}@example.com'}) + child = self.env['res.partner'].create({'name': name}) + return self.env['community.school.student'].create({ + 'partner_id': child.id, 'parent_partner_id': parent.id, + }) + + def test_enrollment_creates_invoice_when_class_has_fee(self): + student = self._make_student('Fee Student') + enrollment = self.env['community.school.enrollment'].create({ + 'student_id': student.id, 'class_id': self.klass.id, + }) + invoice = enrollment._create_fee_invoice() + self.assertTrue(invoice) + self.assertEqual(invoice.state, 'draft') + self.assertEqual(enrollment.payment_state, 'invoiced') + + def test_full_class_waitlists_second_student(self): + student1 = self._make_student('First Student') + student2 = self._make_student('Second Student') + enrollment1 = self.env['community.school.enrollment'].create({ + 'student_id': student1.id, 'class_id': self.klass.id, + }) + self.assertEqual(enrollment1.state, 'enrolled') + + # max_students=1, so a manual second enrollment past capacity should be + # created as waitlist by the controller logic; simulate that here. + state = 'enrolled' if self.klass.enrolled_count < self.klass.max_students else 'waitlist' + enrollment2 = self.env['community.school.enrollment'].create({ + 'student_id': student2.id, 'class_id': self.klass.id, 'state': state, + }) + self.assertEqual(enrollment2.state, 'waitlist') + + def test_waitlist_promoted_when_seat_frees(self): + student1 = self._make_student('Withdraw Student') + student2 = self._make_student('Waitlist Student') + enrollment1 = self.env['community.school.enrollment'].create({ + 'student_id': student1.id, 'class_id': self.klass.id, + }) + enrollment2 = self.env['community.school.enrollment'].create({ + 'student_id': student2.id, 'class_id': self.klass.id, 'state': 'waitlist', + }) + + enrollment1.write({'state': 'withdrawn'}) + self.klass.invalidate_recordset() + self.assertEqual(self.klass.enrolled_count, 0) + + mail_count_before = self.env['mail.mail'].search_count([]) + self.env['community.school.enrollment']._cron_promote_waitlist() + + self.assertEqual(enrollment2.state, 'enrolled') + mail_count_after = self.env['mail.mail'].search_count([]) + self.assertGreater(mail_count_after, mail_count_before) + + def test_lms_enrol_and_unenrol(self): + student = self._make_student('LMS Student') + enrollment = self.env['community.school.enrollment'].create({ + 'student_id': student.id, 'class_id': self.klass.id, + }) + channel = self.klass.slide_channel_id + membership = self.env['slide.channel.partner'].sudo().search([ + ('channel_id', '=', channel.id), ('partner_id', '=', student.partner_id.id), + ]) + self.assertTrue(membership, "Enrolling should add the student to the class's slide channel") + + enrollment.write({'state': 'withdrawn'}) + membership.invalidate_recordset() + self.assertFalse(membership.active, "Withdrawing should deactivate the LMS membership") diff --git a/addons/community_school/views/registration_templates.xml b/addons/community_school/views/registration_templates.xml new file mode 100644 index 0000000..5f30586 --- /dev/null +++ b/addons/community_school/views/registration_templates.xml @@ -0,0 +1,101 @@ + + + + +