Files
banban/talkingq-url/scripts/import_device_imei_mapping.py
2026-05-09 09:46:06 +08:00

190 lines
6.4 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import pymysql
from pymysql.cursors import DictCursor
from config import settings
SERIAL_NUMBER_COLUMNS = ("serial_number", "device_sn")
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS device_imei_mapping (
id INT NOT NULL AUTO_INCREMENT,
imei VARCHAR(64) NOT NULL,
device_id VARCHAR(64) NOT NULL,
serial_number VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
activated_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE INDEX imei_UNIQUE (imei ASC),
UNIQUE INDEX device_id_UNIQUE (device_id ASC),
INDEX idx_device_imei_mapping_status (status ASC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Import IMEI to device identity mappings from a CSV file."
)
parser.add_argument("csv_path", help="CSV file with imei, device_id, serial_number columns.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate the CSV without writing to the database.",
)
return parser.parse_args()
def normalize_row(row: dict[str, str], row_number: int) -> dict[str, str]:
serial_number = ""
for column in SERIAL_NUMBER_COLUMNS:
value = str(row.get(column) or "").strip()
if value:
serial_number = value
break
normalized = {
"imei": str(row.get("imei") or "").strip(),
"device_id": str(row.get("device_id") or "").strip(),
"serial_number": serial_number,
}
missing = [key for key, value in normalized.items() if not value]
if missing:
raise ValueError(f"row {row_number}: missing required columns: {', '.join(missing)}")
return normalized
def read_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8-sig", newline="") as fp:
reader = csv.DictReader(fp)
fieldnames = set(reader.fieldnames or [])
missing = [column for column in ("imei", "device_id") if column not in fieldnames]
if not any(column in fieldnames for column in SERIAL_NUMBER_COLUMNS):
missing.append("serial_number/device_sn")
if missing:
raise ValueError(f"CSV missing required columns: {', '.join(missing)}")
rows: list[dict[str, str]] = []
seen_imei: set[str] = set()
seen_device_id: set[str] = set()
for row_number, row in enumerate(reader, start=2):
normalized = normalize_row(row, row_number)
if normalized["imei"] in seen_imei:
raise ValueError(f"row {row_number}: duplicate imei in CSV: {normalized['imei']}")
if normalized["device_id"] in seen_device_id:
raise ValueError(f"row {row_number}: duplicate device_id in CSV: {normalized['device_id']}")
seen_imei.add(normalized["imei"])
seen_device_id.add(normalized["device_id"])
rows.append(normalized)
return rows
def get_connection():
return pymysql.connect(
host=settings.db_host,
port=settings.db_port,
user=settings.db_user,
password=settings.db_password,
database=settings.db_name,
charset="utf8mb4",
cursorclass=DictCursor,
)
def import_rows(rows: list[dict[str, str]]) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(CREATE_TABLE_SQL)
for row in rows:
cur.execute(
"""
SELECT imei, device_id, serial_number, status
FROM device_imei_mapping
WHERE imei = %s OR device_id = %s
""",
(row["imei"], row["device_id"]),
)
existing_rows = cur.fetchall()
if len(existing_rows) > 1:
raise ValueError(
f"conflicting mapping: imei={row['imei']} device_id={row['device_id']}"
)
if not existing_rows:
cur.execute(
"""
INSERT INTO device_imei_mapping (imei, device_id, serial_number, status)
VALUES (%s, %s, %s, 'pending')
""",
(row["imei"], row["device_id"], row["serial_number"]),
)
continue
existing = existing_rows[0]
if existing["imei"] != row["imei"]:
raise ValueError(
f"device_id already mapped to another imei: {row['device_id']}"
)
is_activated = str(existing["status"]) == "activated"
changed_identity = (
existing["device_id"] != row["device_id"]
or existing["serial_number"] != row["serial_number"]
)
if is_activated and changed_identity:
raise ValueError(f"activated imei cannot be remapped: {row['imei']}")
cur.execute(
"""
UPDATE device_imei_mapping
SET device_id = %s,
serial_number = %s,
status = IF(status = 'activated', status, 'pending')
WHERE imei = %s
""",
(row["device_id"], row["serial_number"], row["imei"]),
)
conn.commit()
return len(rows)
except Exception:
conn.rollback()
raise
finally:
conn.close()
def main() -> int:
args = parse_args()
csv_path = Path(args.csv_path).expanduser().resolve()
try:
rows = read_rows(csv_path)
if args.dry_run:
print(f"validated {len(rows)} rows from {csv_path}")
return 0
imported = import_rows(rows)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(f"imported {imported} rows into device_imei_mapping")
return 0
if __name__ == "__main__":
raise SystemExit(main())