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.
98 lines
4.2 KiB
Python
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()
|