TNCSC_Odoo/scripts/migrate_classroom.py
metatroncubeswdev e05448b939 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>
2026-08-17 21:42:51 -04:00

157 lines
5.7 KiB
Python

#!/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())