TNCSC_Odoo/scripts/migrate_wp_members.py
metatroncubeswdev 657792b268
Some checks failed
CI / Brand-leak check (push) Has been cancelled
CI / flake8 / pylint-odoo / manifest completeness (push) Has been cancelled
CI / Install all modules with --test-enable (push) Has been cancelled
Add member application fields and the six real TNCSC membership plans
community_membership:
- Tiers get a fixed-term period (term_years), minimum age, a
  proof-document flag, a family-member flag and a signup description.
- Members get first/last name (composing the contact name), month and
  year of birth with computed age, board approval state with
  approve/reject buttons, terms acceptance, a proof-of-eligibility upload
  and an inline family members list. Birth date and proof document are
  restricted to Membership Managers.
- Constraints: plausible birth year; a tier's minimum age is enforced
  when the birth year is known.
- Fix pre-existing tests that collided with deployment-seeded tier codes
  and member ID format.

tncsc_deployment:
- Five Yes/No eligibility questions from the tncsc.com registration form
  (manager-only), shown in an Eligibility section of the member form.
- Tiers now match tncsc.com/membership-registration: Annual/Family $30,
  Long-Term (10 yr) $250, Life $500, Senior Annual (65+) $15, Senior Life
  (60+) $250, Student Annual $10 (CAD).
- 19.0.1.1.0 post-migrate rewrites existing tiers (noupdate data) and
  archives the unused Individual tier.

scripts/migrate_wp_members.py maps WordPress levels to the new LONG and
SENLIFE tiers. .mcp.json (holds credentials) is now gitignored.

Verified on the local Docker stack: -u on tncsc_site ran the migration
cleanly, and a fresh install with --test-enable passed 32/32 tests for
community_membership and tncsc_deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:46:31 -04:00

242 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""Migrate TNCSC's real WordPress member export (users.csv) into Community
OS Membership.
This is deliberately separate from migrate_members.py, which expects a
clean, documented schema. users.csv is a real WordPress/WooCommerce export
with messy real-world data: duplicate "Country"/"State" columns (WordPress
exports the real profile ones AND a pair of empty leftover checkout-form
columns under the same header names - this script reads by column
*position*, not name, to get the real ones), free-text membership levels
that don't map 1:1 to our tier codes, ~229/493 rows with no legacy
membership ID at all, and several sensitive columns (date of birth,
citizenship/immigration-eligibility Yes/No answers) that have no field in
this system and are deliberately never read here.
Behaviour, confirmed with TNCSC before writing this:
- Rows whose email is on the @abbuzz.com test/spam domain are skipped
entirely (not real members).
- "Level" maps to a tier code: Annual/Family -> FAM, Long-Term
Membership -> LONG, Life Term Membership -> LIFE, Student Annual
Membership -> STU, Senior Annual Membership -> SEN, Senior Life Term ->
SENLIFE. Blank Level, or a comma-separated multi-value Level
(ambiguous - can't tell which one is current), is imported as a
contact with NO tier set and flagged 'needs_review' in the report -
never auto-guessed.
- The legacy "TNCSC Membership ID" column (where present) is never used
as the new member ID - the system generates its own via the
configured Member ID Format. Instead it's preserved as an internal
note (chatter message) on the partner, alongside the original raw
Level and Join Date, so it's still there for reference/lookup.
- Existing members are matched by email (case-insensitive) and updated;
re-running the same file converges rather than duplicating.
- State/province is intentionally NOT set - the source data for it is
frequently garbage in this export (e.g. a city name duplicated into
the state column, or blank), and guessing wrong is worse than leaving
it blank for staff to fill in. Country is resolved by name lookup.
Usage:
python scripts/migrate_wp_members.py \\
--url https://your-odoo-host --db tncsc_site \\
--username admin --password admin \\
--csv users.csv \\
--report data/raw/wp_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_email, normalize_phone
EXPECTED_HEADER_PREFIX = ['Username', 'Email', 'First Name', 'Last Name']
JUNK_EMAIL_DOMAINS = {'abbuzz.com'}
# Column indices, verified against the real export - see module docstring
# for why these are read positionally rather than by (duplicated) name.
COL_EMAIL = 1
COL_FIRST_NAME = 2
COL_LAST_NAME = 3
COL_PHONE = 6
COL_ADDRESS1 = 7
COL_ADDRESS2 = 8
COL_ZIP = 9
COL_CITY = 10
COL_STATE = 11
COL_COUNTRY = 12
COL_LEGACY_MEMBER_ID = 27
COL_LEVEL = 28
COL_JOIN_DATE = 30
LEVEL_TO_TIER_CODE = {
'Annual/Family': 'FAM',
'Long-Term Membership': 'LONG',
'Life Term Membership': 'LIFE',
'Student Annual Membership': 'STU',
'Senior Annual Membership': 'SEN',
'Senior Life Term': 'SENLIFE',
}
def read_rows(csv_path):
with open(csv_path, newline='', encoding='utf-8-sig') as handle:
reader = csv.reader(handle)
header = next(reader)
if header[:4] != EXPECTED_HEADER_PREFIX:
raise RuntimeError(
f"CSV header doesn't match the expected users.csv shape "
f"(first 4 columns were {header[:4]!r}) - this script reads "
f"columns by position, so a differently-shaped export would "
f"silently misimport. Update the COL_* indices first."
)
for row in reader:
yield row
def parse_join_date(value):
"""'2018-09-24 16:34:25' -> '2018-09-24'. Returns None if unparseable."""
value = (value or '').strip()
if not value:
return None
date_part = value.split(' ')[0]
parts = date_part.split('-')
if len(parts) == 3 and all(p.isdigit() for p in parts):
return date_part
return None
def resolve_tier(level_raw, tier_by_code):
"""Returns (tier_id_or_None, needs_review_reason_or_None)."""
level = (level_raw or '').strip()
if not level:
return None, None
if ',' in level:
return None, f"ambiguous multi-value Level '{level}' - pick manually"
code = LEVEL_TO_TIER_CODE.get(level)
if not code:
return None, f"unrecognized Level '{level}' - pick manually"
tier_id = tier_by_code.get(code)
if not tier_id:
return None, f"tier code '{code}' (from Level '{level}') not found in this database"
return tier_id, None
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']
countries_by_name = {}
for country in rpc('res.country', 'search_read', [[]], {'fields': ['name']}):
countries_by_name[country['name'].strip().lower()] = country['id']
seen_emails = set()
for row_number, row in enumerate(read_rows(args.csv), start=2):
email = normalize_email(row[COL_EMAIL])
key = email or f'row {row_number}'
if not email:
report.add(key, 'failed', 'missing email')
continue
if email.rsplit('@', 1)[-1] in JUNK_EMAIL_DOMAINS:
report.add(key, 'skipped', 'test/spam domain')
continue
if email in seen_emails:
report.add(key, 'failed', 'duplicate email in source file')
continue
seen_emails.add(email)
first_name = (row[COL_FIRST_NAME] or '').strip()
last_name = (row[COL_LAST_NAME] or '').strip()
name = f'{first_name} {last_name}'.strip()
if not name:
report.add(key, 'failed', 'missing first and last name')
continue
tier_id, tier_review_reason = resolve_tier(row[COL_LEVEL], tier_by_code)
vals = {
'name': name,
'email': email,
'phone': normalize_phone(row[COL_PHONE]),
'street': (row[COL_ADDRESS1] or '').strip() or False,
'street2': (row[COL_ADDRESS2] or '').strip() or False,
'zip': (row[COL_ZIP] or '').strip() or False,
'city': (row[COL_CITY] or '').strip() or False,
}
country_name = (row[COL_COUNTRY] or '').strip().lower()
if country_name in countries_by_name:
vals['country_id'] = countries_by_name[country_name]
if tier_id:
vals['membership_tier_id'] = tier_id
join_date = parse_join_date(row[COL_JOIN_DATE])
if join_date:
vals['membership_start'] = join_date
existing = rpc('res.partner', 'search', [[('email', '=', email)]], {'limit': 1})
if args.dry_run:
action = 'would_update' if existing else 'would_create'
report.add(key, 'needs_review' if tier_review_reason else action, tier_review_reason or '')
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'
legacy_id = (row[COL_LEGACY_MEMBER_ID] or '').strip()
raw_level = (row[COL_LEVEL] or '').strip()
if legacy_id or raw_level:
note_lines = ['Imported from TNCSC WordPress export (users.csv):']
if legacy_id:
note_lines.append(f'- Legacy TNCSC Membership ID: {legacy_id}')
if raw_level:
note_lines.append(f'- Original WordPress "Level": {raw_level}')
rpc('res.partner', 'message_post', [[partner_id]], {
'body': '<br/>'.join(note_lines),
})
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, 'needs_review' if tier_review_reason else action, tier_review_reason or '')
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())