407 lines
13 KiB
Python
407 lines
13 KiB
Python
#!/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 <token>
|
|
|
|
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()
|