sync
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user