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:
parent
7ac5880f20
commit
e05448b939
@ -33,10 +33,12 @@ with no code change.
|
|||||||
'security/school_security.xml',
|
'security/school_security.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/mail_templates.xml',
|
'data/mail_templates.xml',
|
||||||
|
'data/ir_cron.xml',
|
||||||
'views/school_views.xml',
|
'views/school_views.xml',
|
||||||
'views/school_menus.xml',
|
'views/school_menus.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/portal_attendance_templates.xml',
|
'views/portal_attendance_templates.xml',
|
||||||
|
'views/registration_templates.xml',
|
||||||
],
|
],
|
||||||
'demo': [],
|
'demo': [],
|
||||||
'assets': {
|
'assets': {
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
from . import attendance
|
from . import attendance
|
||||||
|
from . import registration
|
||||||
|
|||||||
86
addons/community_school/controllers/registration.py
Normal file
86
addons/community_school/controllers/registration.py
Normal 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',
|
||||||
|
})
|
||||||
14
addons/community_school/data/ir_cron.xml
Normal file
14
addons/community_school/data/ir_cron.xml
Normal 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>
|
||||||
@ -18,6 +18,24 @@
|
|||||||
on <t t-out="format_date(object.date)"/>.
|
on <t t-out="format_date(object.date)"/>.
|
||||||
</p>
|
</p>
|
||||||
<p t-if="object.notes">Notes: <t t-out="object.notes"/></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>
|
</div>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@ -16,6 +16,9 @@ class CommunitySchoolClass(models.Model):
|
|||||||
term_id = fields.Many2one('community.school.term', required=True)
|
term_id = fields.Many2one('community.school.term', required=True)
|
||||||
teacher_id = fields.Many2one('res.partner', domain=[('is_teacher', '=', True)])
|
teacher_id = fields.Many2one('res.partner', domain=[('is_teacher', '=', True)])
|
||||||
max_students = fields.Integer(default=20)
|
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)
|
enrolled_count = fields.Integer(compute='_compute_enrolled_count', store=True)
|
||||||
weekday = fields.Selection(WEEKDAYS)
|
weekday = fields.Selection(WEEKDAYS)
|
||||||
start_time = fields.Float()
|
start_time = fields.Float()
|
||||||
@ -57,6 +60,21 @@ class CommunitySchoolClass(models.Model):
|
|||||||
self.slide_channel_id = channel.id
|
self.slide_channel_id = channel.id
|
||||||
return channel
|
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
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
classes = super().create(vals_list)
|
classes = super().create(vals_list)
|
||||||
|
|||||||
@ -53,3 +53,78 @@ class CommunitySchoolEnrollment(models.Model):
|
|||||||
if enrollment.state == 'waitlist' and enrollment.class_id.enrolled_count < enrollment.class_id.max_students:
|
if enrollment.state == 'waitlist' and enrollment.class_id.enrolled_count < enrollment.class_id.max_students:
|
||||||
enrollment.state = 'enrolled'
|
enrollment.state = 'enrolled'
|
||||||
return True
|
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)
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
from . import test_school
|
from . import test_school
|
||||||
from . import test_school_attendance
|
from . import test_school_attendance
|
||||||
|
from . import test_school_registration
|
||||||
|
|||||||
84
addons/community_school/tests/test_school_registration.py
Normal file
84
addons/community_school/tests/test_school_registration.py
Normal 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")
|
||||||
101
addons/community_school/views/registration_templates.xml
Normal file
101
addons/community_school/views/registration_templates.xml
Normal 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>
|
||||||
156
scripts/migrate_classroom.py
Normal file
156
scripts/migrate_classroom.py
Normal 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user