MEGAINFRA-WEBAPP/scripts/site-model/upload.py
DEls 0dfb129696 feat: update management comparisons and Cedar Gate site model
Add planned vs actual turnover dashboards, monthly actual costs, editable retention forecasts and actuals, starting retention, payouts and running balances. Preserve manual values across workbook imports and include retention backfill tooling.

Add Cedar Gate model extraction and reports, measured service routes and ground surfaces, plus site-map section totals and quantity refinements.
2026-10-20 12:23:44 +02:00

98 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Upload a built site model and make it the site's active model.
python3 scripts/site-model/upload.py --site <site record id> --label "Bushveld Joy Road — RD LO Rev 1" /tmp/site-model-hills
add --dry-run to only print the key report.
Creates a site_models row (status active) with the five JSON files, archives the previous
active model, and prints progress keys recorded on the site that the new model no longer has.
"""
import argparse
import json
import sys
import uuid
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from pb_env import PB, http, superuser_token # noqa: E402
from model_keys import model_keys # noqa: E402
FILES = ["bushveld", "services", "earthworks", "schedules", "surface"]
def multipart(fields, files):
boundary = uuid.uuid4().hex
out = b""
for name, value in fields.items():
out += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
for field, filename, data, ctype in files:
out += f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; filename="{filename}"\r\nContent-Type: {ctype}\r\n\r\n'.encode()
out += data + b"\r\n"
out += f"--{boundary}--\r\n".encode()
return out, f"multipart/form-data; boundary={boundary}"
def orphaned(existing_keys, new_keys):
return sorted(existing_keys - new_keys)
def existing_progress_keys(token, site):
keys, page = set(), 1
while True:
code, res = http("GET", f"/api/collections/site_progress_events/records?page={page}&perPage=500&fields=key&filter=" +
urllib.request.quote(f"site='{site}'"), token=token)
if code != 200:
raise RuntimeError(f"Reading progress failed: HTTP {code}")
keys.update(r["key"] for r in res["items"])
if page >= res["totalPages"]:
return keys
page += 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--site", required=True)
ap.add_argument("--label", required=True)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("folder")
args = ap.parse_args()
folder = Path(args.folder)
missing = [f for f in FILES if not (folder / f"{f}.json").exists()]
if missing:
sys.exit(f"Missing model files: {missing}")
token = superuser_token()
new_keys = set(model_keys(folder))
gone = orphaned(existing_progress_keys(token, args.site), new_keys)
print(f"{len(new_keys)} progress items in the new model")
if gone:
print(f"WARNING: {len(gone)} recorded progress keys are not in the new model:")
for k in gone[:50]:
print(" ", k)
if args.dry_run:
return
road = json.loads((folder / "bushveld.json").read_text())
sources = {"folder": str(folder)}
if (folder / "sources.json").exists():
sources["manifest"] = json.loads((folder / "sources.json").read_text())
stats = {"asphalt_m2": round(sum(a["area"] for a in road["areas"] if a["kind"] == "asphalt"), 1), "sections": len(road["sections"])}
body, ctype = multipart(
{"site": args.site, "label": args.label, "status": "active", "built_at": datetime.now(timezone.utc).isoformat(),
"sources": json.dumps(sources), "stats": json.dumps(stats)},
[("files", f"{f}.json", (folder / f"{f}.json").read_bytes(), "application/json") for f in FILES])
req = urllib.request.Request(f"{PB}/api/collections/site_models/records", data=body, method="POST")
req.add_header("Content-Type", ctype); req.add_header("Authorization", token)
with urllib.request.urlopen(req) as r:
created = json.loads(r.read().decode())
print(f"Created model {created['id']} ({args.label}) — active")
code, res = http("GET", "/api/collections/site_models/records?perPage=200&filter=" +
urllib.request.quote(f"site='{args.site}' && status='active' && id!='{created['id']}'"), token=token)
for old in res.get("items", []):
http("PATCH", f"/api/collections/site_models/records/{old['id']}", {"status": "archived"}, token)
print(f"Archived previous model {old['id']}")
if __name__ == "__main__":
main()