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>
157 lines
5.7 KiB
Python
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())
|