TNCSC_Odoo/scripts/migrate_members.py
metatroncubeswdev 8c8043a49a docs: add HANDOFF.md, commit Phase 8 WIP
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>
2026-08-18 07:29:29 -04:00

147 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Migrate existing TNCSC members into Community OS Membership.
Input CSV columns (source system export - adjust the header names below to
match the real export once available, the column *names* are what matter,
not their order):
full_name,email,phone,tier_code,membership_start,membership_expiry,is_volunteer
- full_name: required.
- email: required, used as the dedupe/match key.
- phone: optional, normalized to digits (with a leading + kept if present).
- tier_code: required, must match an existing community.membership.tier
code (e.g. IND, FAM, STU, SEN, LIFE for the TNCSC deployment).
- membership_start / membership_expiry: optional, several common date
formats are accepted and normalized to ISO (YYYY-MM-DD).
- is_volunteer: optional, "1"/"true"/"yes" (case-insensitive) = True.
Behaviour:
- Rows missing full_name/email, with an unrecognized tier_code, or that
duplicate an email already seen earlier in the SAME file are marked
'failed' with a reason and skipped - they never reach Odoo.
- Existing members are matched by email (case-insensitive) and updated;
new ones are created. Re-running the same file is safe: it always
converges on the same end state rather than creating duplicates.
- A member with a tier and no explicit state that isn't already active
is activated (generates a member ID) after being created/updated.
Usage:
python scripts/migrate_members.py \\
--url https://your-odoo-host --db communityos_dev \\
--username admin --password admin \\
--csv data/raw/members_export.csv \\
--report data/raw/members_migration_report.csv \\
[--dry-run]
"""
import argparse
import csv
import sys
from pathlib import Path
from _migration_common import (
MigrationReport, authenticate, execute_kw, normalize_date, normalize_email, normalize_phone,
)
def read_rows(csv_path):
with open(csv_path, newline='', encoding='utf-8') as handle:
yield from csv.DictReader(handle)
def is_truthy(value):
return (value or '').strip().lower() in ('1', 'true', 'yes', 'y')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--url', required=True)
parser.add_argument('--db', required=True)
parser.add_argument('--username', required=True)
parser.add_argument('--password', required=True)
parser.add_argument('--csv', required=True, type=Path)
parser.add_argument('--report', required=True, type=Path)
parser.add_argument('--dry-run', action='store_true')
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)
report = MigrationReport()
def rpc(model, method, call_args, kwargs=None):
return execute_kw(args.url, args.db, uid, args.password, model, method, call_args, kwargs)
tier_by_code = {}
for tier in rpc('community.membership.tier', 'search_read', [[]], {'fields': ['code']}):
tier_by_code[tier['code']] = tier['id']
seen_emails = set()
for row_number, row in enumerate(read_rows(args.csv), start=2):
name = (row.get('full_name') or '').strip()
email = normalize_email(row.get('email'))
key = email or f'row {row_number}'
if not name or not email:
report.add(key, 'failed', 'missing full_name or email')
continue
if email in seen_emails:
report.add(key, 'failed', 'duplicate email in source file')
continue
seen_emails.add(email)
tier_code = (row.get('tier_code') or '').strip()
tier_id = tier_by_code.get(tier_code)
if tier_code and not tier_id:
report.add(key, 'failed', f"unknown tier_code '{tier_code}'")
continue
vals = {
'name': name,
'email': email,
'phone': normalize_phone(row.get('phone')),
'is_volunteer': is_truthy(row.get('is_volunteer')),
}
if tier_id:
vals['membership_tier_id'] = tier_id
start = normalize_date(row.get('membership_start'))
if start:
vals['membership_start'] = start
expiry = normalize_date(row.get('membership_expiry'))
if expiry:
vals['membership_expiry'] = expiry
existing = rpc('res.partner', 'search', [[('email', '=', email)]], {'limit': 1})
if args.dry_run:
report.add(key, 'would_update' if existing else 'would_create')
continue
if existing:
rpc('res.partner', 'write', [existing, vals])
partner_id = existing[0]
action = 'updated'
else:
partner_id = rpc('res.partner', 'create', [vals])
action = 'created'
if tier_id:
partner_state = rpc('res.partner', 'read', [[partner_id], ['membership_state']])[0]['membership_state']
if partner_state not in ('active', 'renewal_due'):
rpc('res.partner', 'action_activate_membership', [[partner_id]])
report.add(key, action)
report.write(args.report)
summary = report.summary()
print(f"Done. {summary}")
print(f"Report written to {args.report}")
return 1 if summary.get('failed') else 0
if __name__ == '__main__':
sys.exit(main())