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/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': {
|
||||
|
||||
@ -1 +1,2 @@
|
||||
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)"/>.
|
||||
</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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
from . import test_school
|
||||
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