HANDOFF.md is the "where things actually stand" companion to the plan document: a phase-by-phase status table, how to spin the dev environment back up (including the "restart the container after any module change or you'll hit a stale registry" gotcha that bit repeatedly this session), an index of every real Odoo 19 API-drift gotcha discovered during the build (pointing at the commit that documents each in full rather than duplicating it), and instructions for pushing this repo to a remote before handing it to a team (none is configured yet - this repo only exists locally). Also commits the two Phase 8 files that existed only as uncommitted local changes (scripts/_migration_common.py, a first pass at scripts/migrate_members.py) so they survive a git clone rather than being fragile local-only WIP. Neither is wired into anything or tested yet - migrate_members.py has no live run against Odoo, and migrate_students.py / migrate_opening_balances.py don't exist yet. Paused here at the user's request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
3.0 KiB
Python
95 lines
3.0 KiB
Python
"""Shared JSON-RPC helpers for the standalone migration scripts in this
|
|
directory (migrate_members.py, migrate_students.py,
|
|
migrate_opening_balances.py, migrate_classroom.py).
|
|
|
|
Deliberately stdlib-only (no external dependencies) so these scripts can run
|
|
on any machine with Python 3, without needing the project's own virtualenv
|
|
or the Odoo MCP server - they talk to a deployed Odoo instance purely over
|
|
JSON-RPC.
|
|
"""
|
|
import csv
|
|
import json
|
|
import re
|
|
import urllib.request
|
|
from datetime import datetime
|
|
|
|
|
|
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 normalize_email(value):
|
|
return (value or '').strip().lower() or None
|
|
|
|
|
|
def normalize_phone(value):
|
|
value = (value or '').strip()
|
|
if not value:
|
|
return None
|
|
keep_plus = value.startswith('+')
|
|
digits = re.sub(r'\D', '', value)
|
|
return ('+' if keep_plus else '') + digits
|
|
|
|
|
|
def normalize_date(value):
|
|
"""Parse a handful of common export date formats into ISO (YYYY-MM-DD)."""
|
|
value = (value or '').strip()
|
|
if not value:
|
|
return None
|
|
for fmt in ('%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y/%m/%d', '%B %d, %Y', '%b %d, %Y'):
|
|
try:
|
|
return datetime.strptime(value, fmt).date().isoformat()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
class MigrationReport:
|
|
"""Accumulates per-row outcomes and writes them to a report CSV."""
|
|
|
|
def __init__(self):
|
|
self.rows = []
|
|
|
|
def add(self, key, action, reason=''):
|
|
self.rows.append({'key': key, 'action': action, 'reason': reason})
|
|
|
|
def write(self, path):
|
|
with open(path, 'w', newline='', encoding='utf-8') as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=['key', 'action', 'reason'])
|
|
writer.writeheader()
|
|
writer.writerows(self.rows)
|
|
|
|
def summary(self):
|
|
counts = {}
|
|
for row in self.rows:
|
|
counts[row['action']] = counts.get(row['action'], 0) + 1
|
|
return counts
|