#!/usr/bin/env python3 """Import ServicePoints.csv through the same API the app uses to add a point. Login (staff, same as the app): POST {base}/auth/staff-login {"phone": "...", "password": "..."} Create (same body as ServicePointFormScreen): POST {base}/service-points {"name", "district", "address", "phone", "notes"} Authorization: Bearer The CSV has no header. Columns are town, name, address, phone. `district` must be one of the names in lib/labels.dart (sriLankaDistricts). The spreadsheet town is stored in `notes` so the finer location is kept. Examples: python3 scripts/import_service_points.py --dry-run python3 scripts/import_service_points.py --phone 07XXXXXXXX --password '***' SENARO_TOKEN=eyJ... python3 scripts/import_service_points.py """ from __future__ import annotations import argparse import csv import getpass import json import os import sys import urllib.error import urllib.request from collections import Counter from pathlib import Path DEFAULT_BASE_URL = "https://snrodealer.playpoolstudios.com/api" ROOT = Path(__file__).resolve().parent.parent # Official district names, matching lib/labels.dart. DISTRICTS = ( "Ampara", "Anuradhapura", "Badulla", "Batticaloa", "Colombo", "Galle", "Gampaha", "Hambantota", "Jaffna", "Kalutara", "Kandy", "Kegalle", "Kilinochchi", "Kurunegala", "Mannar", "Matale", "Matara", "Moneragala", "Mullaitivu", "Nuwara Eliya", "Polonnaruwa", "Puttalam", "Ratnapura", "Trincomalee", "Vavuniya", ) # Spreadsheet town (normalized) -> official district. TOWN_TO_DISTRICT = { # Ampara "akkarapattu": "Ampara", "ampara": "Ampara", "dehiattakandiya": "Ampara", "hingurana": "Ampara", "kalmunai": "Ampara", "samanthurai": "Ampara", # Anuradhapura "anuradhapura": "Anuradhapura", "eppawala": "Anuradhapura", "galenbidunuwewa": "Anuradhapura", "horowpothana": "Anuradhapura", "kahatagasdigiliya": "Anuradhapura", "kebitigollewa": "Anuradhapura", "kekirawa": "Anuradhapura", "madawachchiya": "Anuradhapura", "parakramapura": "Anuradhapura", "thambuththegama": "Anuradhapura", # Badulla "badulla": "Badulla", "bandarawela": "Badulla", "mahiyanganaya": "Badulla", "welimada": "Badulla", # Batticaloa "batticoloa": "Batticaloa", "kokkadicholai": "Batticaloa", # Colombo "avissawella": "Colombo", "hanwella": "Colombo", "kotikawatta": "Colombo", "kottawa": "Colombo", "malabe": "Colombo", "moratuwa": "Colombo", "panchikawatte": "Colombo", "piliyandala": "Colombo", "polgasowita": "Colombo", # Galle "ambalangoda": "Galle", "baddegama": "Galle", "batapola": "Galle", "elpitiya": "Galle", "galle": "Galle", "pitigala": "Galle", "udugama": "Galle", # Gampaha "biyagama": "Gampaha", "divulapitiya": "Gampaha", "gampaha": "Gampaha", "ganemulla": "Gampaha", "kadawatha": "Gampaha", "kandana": "Gampaha", "kiridiwela": "Gampaha", "minuwangoda": "Gampaha", "negombo": "Gampaha", "wattala": "Gampaha", "weliweriya": "Gampaha", "yakkala": "Gampaha", # Hambantota "agunukolapalassa": "Hambantota", "ambalanthota": "Hambantota", "middeniya": "Hambantota", "thissamaharama": "Hambantota", "walasmulla": "Hambantota", # Jaffna "chavakachchri": "Jaffna", "jaffna": "Jaffna", # Kalutara "aluthgama": "Kalutara", "bandaragama": "Kalutara", "horana": "Kalutara", "ingiriya": "Kalutara", "kaluthara": "Kalutara", "maggona": "Kalutara", "mathugama": "Kalutara", "panadura": "Kalutara", # Kandy "digana": "Kandy", "gampola": "Kandy", "kandy": "Kandy", "nawalapitiya": "Kandy", "teldeniya": "Kandy", # Kegalle "dehiowita": "Kegalle", "kegalle": "Kegalle", "kithulgala": "Kegalle", "warakapola": "Kegalle", # Kilinochchi "kilinochchi": "Kilinochchi", # Kurunegala "bingiriya": "Kurunegala", "galgamuwa": "Kurunegala", "kuliyapitiya": "Kurunegala", "kurunegala": "Kurunegala", "mahawa": "Kurunegala", "mawathagama": "Kurunegala", "narammala": "Kurunegala", "nikawaratiya": "Kurunegala", "polgahawela": "Kurunegala", "polpitigama": "Kurunegala", "wariyapola": "Kurunegala", # Mannar "mannar": "Mannar", # Matale "dambulla": "Matale", "matale": "Matale", # Matara "akuressa": "Matara", "kamburupitiya": "Matara", "matara": "Matara", "pitabeddara": "Matara", "urubokka": "Matara", # Moneragala "butthala": "Moneragala", "monaragala": "Moneragala", "wellawaya": "Moneragala", # Mullaitivu "puthukkudiyiruppu": "Mullaitivu", # Nuwara Eliya "hatton": "Nuwara Eliya", "nuwara eliya": "Nuwara Eliya", "thalawakele": "Nuwara Eliya", # Polonnaruwa "aralaganwila": "Polonnaruwa", "manampitiya": "Polonnaruwa", "medirigiriya": "Polonnaruwa", "polonnaruwa": "Polonnaruwa", "sevanapitiya": "Polonnaruwa", # Puttalam "anamaduwa": "Puttalam", "galmuruwa": "Puttalam", "halawatha (chillaw)": "Puttalam", "nathandiya": "Puttalam", "norochchole": "Puttalam", # Ratnapura "balangoda": "Ratnapura", "eheliyagoda": "Ratnapura", "embilipitiya": "Ratnapura", "kalawana": "Ratnapura", "opanayaka": "Ratnapura", "pelmadulla": "Ratnapura", "rathnapura": "Ratnapura", # Trincomalee "kantale": "Trincomalee", "kinniya": "Trincomalee", "trincomalee": "Trincomalee", # Vavuniya "vauniya": "Vavuniya", } class ApiError(Exception): def __init__(self, status: int, message: str): super().__init__(f"HTTP {status}: {message}") self.status = status self.message = message def normalize(value: str) -> str: return " ".join(value.strip().casefold().split()) def request_json(method: str, url: str, body: dict | None = None, token: str | None = None) -> dict: data = None if body is None else json.dumps(body).encode() headers = {"Accept": "application/json", "Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=30) as response: raw = response.read().decode() return json.loads(raw) if raw else {} except urllib.error.HTTPError as error: raw = error.read().decode() message = raw or str(error.reason) try: payload = json.loads(raw) except json.JSONDecodeError: payload = None if isinstance(payload, dict) and isinstance(payload.get("message"), str): message = payload["message"] raise ApiError(error.code, message) from error def load_rows(path: Path) -> list[dict]: rows: list[dict] = [] unmapped: list[tuple[int, str]] = [] with path.open(newline="", encoding="utf-8-sig") as handle: for line_no, record in enumerate(csv.reader(handle), start=1): if not record or all(not cell.strip() for cell in record): continue if len(record) != 4: raise SystemExit(f"{path}:{line_no}: expected 4 columns, got {len(record)}") town, name, address, phone = (cell.strip() for cell in record) district = TOWN_TO_DISTRICT.get(normalize(town)) if district not in DISTRICTS: unmapped.append((line_no, town)) continue if not name: raise SystemExit(f"{path}:{line_no}: missing service point name") rows.append( { "line": line_no, "town": town, "body": { "name": name, "district": district, "address": address, "phone": phone, "notes": town, }, } ) if unmapped: print("Unmapped towns (add them to TOWN_TO_DISTRICT before importing):", file=sys.stderr) for line_no, town in unmapped: print(f" line {line_no}: {town}", file=sys.stderr) raise SystemExit(2) return rows def point_key(name: str, district: str, phone: str) -> tuple[str, str, str]: return (normalize(name), district, normalize(phone)) def existing_keys(base_url: str, token: str) -> set[tuple[str, str, str]]: payload = request_json("GET", f"{base_url}/service-points", token=token) items = payload.get("data", payload) if not isinstance(items, list): raise SystemExit("Unexpected /service-points response; expected a data list") keys: set[tuple[str, str, str]] = set() for item in items: if not isinstance(item, dict): continue keys.add( point_key( str(item.get("name") or ""), str(item.get("district") or ""), str(item.get("phone") or ""), ) ) return keys def login(base_url: str, phone: str, password: str) -> str: payload = request_json( "POST", f"{base_url}/auth/staff-login", {"phone": phone, "password": password}, ) token = payload.get("token") if not isinstance(token, str) or not token: raise SystemExit("Staff login succeeded but no token was returned") user = payload.get("user") if isinstance(payload.get("user"), dict) else {} name = user.get("name") or user.get("phone") or phone print(f"Signed in as {name}") return token def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--csv", type=Path, default=ROOT / "ServicePoints.csv", help="CSV path") parser.add_argument("--base-url", default=os.environ.get("SENARO_API_BASE_URL", DEFAULT_BASE_URL)) parser.add_argument("--phone", default=os.environ.get("SENARO_PHONE"), help="Staff phone") parser.add_argument("--password", default=os.environ.get("SENARO_PASSWORD"), help="Staff password") parser.add_argument("--token", default=os.environ.get("SENARO_TOKEN"), help="Existing bearer token") parser.add_argument("--dry-run", action="store_true", help="Print payloads and do not call the API") parser.add_argument( "--no-skip-existing", action="store_true", help="Create a point even when the same name, district, and phone already exist", ) return parser.parse_args() def main() -> None: args = parse_args() base_url = args.base_url.rstrip("/") rows = load_rows(args.csv) counts = Counter(row["body"]["district"] for row in rows) print(f"{len(rows)} service points in {args.csv}") for district in DISTRICTS: if counts[district]: print(f" {district}: {counts[district]}") if args.dry_run: print() for row in rows: body = row["body"] print(f"{body['district']}\t{row['town']}\t{body['name']}\t{body['phone']}") print("\nDry run only. Nothing was sent.") return token = args.token if not token: phone = args.phone or input("Staff phone: ").strip() password = args.password or getpass.getpass("Staff password: ") if not phone or not password: raise SystemExit("Staff phone and password are required") token = login(base_url, phone, password) known: set[tuple[str, str, str]] = set() if not args.no_skip_existing: known = existing_keys(base_url, token) print(f"{len(known)} service points already on the server") created = 0 skipped = 0 failed: list[str] = [] for row in rows: body = row["body"] key = point_key(body["name"], body["district"], body["phone"]) label = f"line {row['line']}: {body['name']} ({body['district']})" if key in known: skipped += 1 print(f"skip {label}") continue try: request_json("POST", f"{base_url}/service-points", body, token=token) except ApiError as error: failed.append(f"{label}: {error.message}") print(f"fail {label}: {error.message}", file=sys.stderr) continue except urllib.error.URLError as error: failed.append(f"{label}: {error.reason}") print(f"fail {label}: {error.reason}", file=sys.stderr) continue known.add(key) created += 1 print(f"added {label}") print(f"\nAdded {created}, skipped {skipped}, failed {len(failed)}") if failed: raise SystemExit(1) if __name__ == "__main__": main()