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 @@
+
+
+
+
+
+
Register - Step 1 of 3: Parent/Guardian
+
+
+
+
+
+
+
+
+
Register - Step 2 of 3: Student
+
+
+
+
+
+
+
+
+
Register - Step 3 of 3: Class
+
+
+
+
+
+
+
+
+
+
+
You're on the Waitlist
+
The class is currently full. We'll notify you as soon as a seat opens up.
+
+
+
+
+
Registration Complete
+
is enrolled in
+
.
+
+
+
+
+
+
diff --git a/scripts/migrate_classroom.py b/scripts/migrate_classroom.py
new file mode 100644
index 0000000..7247d26
--- /dev/null
+++ b/scripts/migrate_classroom.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""Migrate a Google Classroom-style assignment list into a Community OS School
+LMS channel (slide.channel), replacing Google Classroom.
+
+Input CSV columns: title,url
+ - title: the assignment/lesson title.
+ - url: a YouTube URL (embedded as a video slide) or any other link, e.g.
+ a Google Drive document (created as an unpublished document slide
+ with a note asking the admin to verify access / re-upload as PDF,
+ since this script cannot read Google Drive content itself).
+
+Uses Odoo's JSON-RPC API (not the Odoo MCP server) so it can run standalone
+against any deployed instance. Re-running is safe: rows are matched by
+(channel_id, name) and skipped if a slide with that title already exists in
+the channel, unless --force is given.
+
+Usage:
+ python scripts/migrate_classroom.py \\
+ --url https://your-odoo-host --db communityos_dev \\
+ --username admin --password admin \\
+ --channel-id 12 --csv data/raw/classroom_export.csv [--dry-run] [--force]
+"""
+import argparse
+import csv
+import json
+import sys
+import urllib.request
+from pathlib import Path
+
+YOUTUBE_HOSTS = ('youtube.com', 'youtu.be', 'www.youtube.com', 'm.youtube.com')
+
+
+def jsonrpc_call(url, service, method, args):
+ payload = {
+ 'jsonrpc': '2.0',
+ 'method': 'call',
+ 'params': {'service': service, 'method': method, 'args': args},
+ }
+ request = urllib.request.Request(
+ f'{url}/jsonrpc',
+ data=json.dumps(payload).encode('utf-8'),
+ headers={'Content-Type': 'application/json'},
+ )
+ with urllib.request.urlopen(request) as response:
+ result = json.loads(response.read())
+ if 'error' in result:
+ raise RuntimeError(result['error'].get('data', {}).get('message') or result['error'])
+ return result['result']
+
+
+def authenticate(url, db, username, password):
+ uid = jsonrpc_call(url, 'common', 'authenticate', [db, username, password, {}])
+ if not uid:
+ raise RuntimeError('Authentication failed - check --url/--db/--username/--password')
+ return uid
+
+
+def execute_kw(url, db, uid, password, model, method, args, kwargs=None):
+ full_args = [db, uid, password, model, method, args]
+ if kwargs is not None:
+ return jsonrpc_call(url, 'object', 'execute_kw', full_args + [kwargs])
+ return jsonrpc_call(url, 'object', 'execute_kw', full_args)
+
+
+def is_youtube(url_value):
+ return any(host in url_value for host in YOUTUBE_HOSTS)
+
+
+def read_rows(csv_path):
+ with open(csv_path, newline='', encoding='utf-8') as handle:
+ reader = csv.DictReader(handle)
+ for row in reader:
+ title = (row.get('title') or '').strip()
+ link = (row.get('url') or '').strip()
+ if title and link:
+ yield title, link
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('--url', required=True, help="Odoo base URL, e.g. http://localhost:8069")
+ parser.add_argument('--db', required=True)
+ parser.add_argument('--username', required=True)
+ parser.add_argument('--password', required=True)
+ parser.add_argument('--channel-id', required=True, type=int, help="Target slide.channel id")
+ parser.add_argument('--csv', required=True, type=Path)
+ parser.add_argument('--dry-run', action='store_true', help="Print what would be done, write nothing")
+ parser.add_argument('--force', action='store_true', help="Recreate slides even if a same-titled one exists")
+ args = parser.parse_args()
+
+ if not args.csv.exists():
+ print(f"CSV file not found: {args.csv}", file=sys.stderr)
+ return 1
+
+ uid = authenticate(args.url, args.db, args.username, args.password)
+
+ existing_titles = set()
+ if not args.force:
+ existing = execute_kw(
+ args.url, args.db, uid, args.password,
+ 'slide.slide', 'search_read',
+ [[['channel_id', '=', args.channel_id]]], {'fields': ['name']},
+ )
+ existing_titles = {record['name'] for record in existing}
+
+ created, skipped, errors = 0, 0, 0
+ for title, link in read_rows(args.csv):
+ if title in existing_titles:
+ print(f"SKIP (already exists): {title}")
+ skipped += 1
+ continue
+
+ if is_youtube(link):
+ vals = {
+ 'name': title,
+ 'channel_id': args.channel_id,
+ 'slide_category': 'video',
+ 'source_type': 'external',
+ 'url': link,
+ 'is_published': True,
+ }
+ kind = 'video'
+ else:
+ vals = {
+ 'name': title,
+ 'channel_id': args.channel_id,
+ 'slide_category': 'document',
+ 'source_type': 'external',
+ 'url': link,
+ 'is_published': False,
+ 'description': (
+ "[MIGRATION] Imported from a non-YouTube link during Google Classroom "
+ "migration. Please verify access and re-upload as a PDF before publishing."
+ ),
+ }
+ kind = 'document (needs review)'
+
+ if args.dry_run:
+ print(f"DRY-RUN would create {kind}: {title} -> {link}")
+ created += 1
+ continue
+
+ try:
+ execute_kw(args.url, args.db, uid, args.password, 'slide.slide', 'create', [vals])
+ print(f"CREATED {kind}: {title}")
+ created += 1
+ except RuntimeError as exc:
+ print(f"ERROR creating '{title}': {exc}", file=sys.stderr)
+ errors += 1
+
+ print(f"\nDone. created={created} skipped={skipped} errors={errors}")
+ return 1 if errors else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())