feat(community_school): multi-step registration, waitlist, LMS glue (Session 3-C)

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>
This commit is contained in:
metatroncubeswdev 2026-08-17 21:42:51 -04:00
parent 7ac5880f20
commit e05448b939
11 changed files with 556 additions and 0 deletions

View File

@ -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': {

View File

@ -1 +1,2 @@
from . import attendance
from . import registration

View File

@ -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',
})

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_school_promote_waitlist" model="ir.cron">
<field name="name">School: Promote Waitlist</field>
<field name="model_id" ref="model_community_school_enrollment"/>
<field name="state">code</field>
<field name="code">model._cron_promote_waitlist()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@ -18,6 +18,24 @@
on <t t-out="format_date(object.date)"/>.
</p>
<p t-if="object.notes">Notes: <t t-out="object.notes"/></p>
</div>
</field>
</record>
<record id="mail_template_seat_available" model="mail.template">
<field name="name">School: Waitlist Seat Available</field>
<field name="model_id" ref="model_community_school_enrollment"/>
<field name="subject">A seat opened up in {{ object.class_id.name }}</field>
<field name="partner_to">{{ object.student_id.parent_partner_id.id }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p>Dear <t t-out="object.student_id.parent_partner_id.name or ''">Parent</t>,</p>
<p>
Good news - a seat has opened up in
<t t-out="object.class_id.name or ''"/>
and <t t-out="object.student_id.partner_id.name or ''"/> has been moved from the
waitlist to enrolled.
</p>
</div>
</field>
</record>

View File

@ -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)

View File

@ -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)

View File

@ -1,2 +1,3 @@
from . import test_school
from . import test_school_attendance
from . import test_school_registration

View File

@ -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")

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="registration_step_parent" name="School Registration: Parent">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;">
<h2>Register - Step 1 of 3: Parent/Guardian</h2>
<form method="POST" t-attf-action="/school/register">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label">Full Name</label>
<input type="text" class="form-control" name="name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Phone</label>
<input type="text" class="form-control" name="phone"/>
</div>
<button type="submit" class="btn btn-primary">Continue</button>
</form>
</div>
</t>
</template>
<template id="registration_step_student" name="School Registration: Student">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;">
<h2>Register - Step 2 of 3: Student</h2>
<form method="POST" t-attf-action="/school/register/student">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label">Student Full Name</label>
<input type="text" class="form-control" name="name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control" name="date_of_birth"/>
</div>
<div class="mb-3">
<label class="form-label">Health Notes</label>
<textarea class="form-control" name="health_notes"/>
</div>
<div class="mb-3">
<label class="form-label">Emergency Contact Name</label>
<input type="text" class="form-control" name="emergency_contact_name"/>
</div>
<div class="mb-3">
<label class="form-label">Emergency Contact Phone</label>
<input type="text" class="form-control" name="emergency_contact_phone"/>
</div>
<button type="submit" class="btn btn-primary">Continue</button>
</form>
</div>
</t>
</template>
<template id="registration_step_class" name="School Registration: Class">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 24px; margin-bottom: 60px;">
<h2>Register - Step 3 of 3: Class</h2>
<form method="POST" t-attf-action="/school/register/class">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label">Class</label>
<select class="form-select" name="class_id" required="required">
<t t-foreach="classes" t-as="klass">
<option t-att-value="klass.id">
<t t-out="klass.name"/> (<t t-out="klass.enrolled_count"/>/<t t-out="klass.max_students"/> seats)
<t t-if="klass.fee"> - <t t-out="klass.fee"/></t>
</option>
</t>
</select>
</div>
<button type="submit" class="btn btn-primary">Complete Registration</button>
</form>
</div>
</t>
</template>
<template id="registration_done" name="School Registration: Done">
<t t-call="website.layout">
<div class="container" style="max-width: 480px; margin-top: 60px; margin-bottom: 60px;">
<t t-if="waitlisted">
<div class="alert alert-warning">
<h2>You're on the Waitlist</h2>
<p>The class is currently full. We'll notify you as soon as a seat opens up.</p>
</div>
</t>
<t t-else="">
<div class="alert alert-success">
<h2>Registration Complete</h2>
<p t-out="enrollment.student_id.partner_id.name"/> is enrolled in
<span t-out="enrollment.class_id.name"/>.
</div>
</t>
</div>
</t>
</template>
</odoo>

View File

@ -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())