MEGAINFRA-WEBAPP/scripts/pb-add-management.py

182 lines
8.6 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Create the management planning collections in PocketBase.
The Management pages read the planning workbook out of PocketBase so admins
can enter actual income, change a project's status and keep an audit trail
(see docs/superpowers/plans/2026-10-19-management-pocketbase.md).
Run: python3 scripts/pb-add-management.py [--dry-run]
--dry-run only reads and prints what would be created; it writes nothing.
The script is idempotent: a missing collection is created, an existing one is
verified (fields, indexes, server-only rules) and never modified. No record is
touched and nothing is ever deleted.
"""
import sys
from pb_env import http, superuser_token
# The planning workbook arrays and the history blob are far bigger than
# PocketBase's 1MB json default.
JSON_MAX_SIZE = 2000000
def main():
dry_run = "--dry-run" in sys.argv[1:]
if any(arg not in ("--dry-run", "--help") for arg in sys.argv[1:]):
sys.exit("Unknown option. Usage: python3 scripts/pb-add-management.py [--dry-run]")
if "--help" in sys.argv[1:]:
print(__doc__.strip())
return
token = superuser_token()
created = []
def read(name):
code, result = http("GET", f"/api/collections/{name}", token=token)
if code != 200:
raise RuntimeError(f"Cannot read {name}: HTTP {code}")
return result
def relation(name, collection, required=True, cascade_delete=False):
return {"name": name, "type": "relation", "required": required,
"collectionId": collection["id"], "maxSelect": 1, "cascadeDelete": cascade_delete}
def json_field(name):
return {"name": name, "type": "json", "required": False, "maxSize": JSON_MAX_SIZE}
def json_number_field(name):
# A json field keeps a missing number null; a number field hands back
# 0 for an empty value, which is not the same as "not supplied".
return {"name": name, "type": "json", "required": False, "maxSize": 1000}
def ensure(name, fields, indexes):
wanted = {"name": name, "type": "base", "fields": fields + [
{"name": "created", "type": "autodate", "onCreate": True, "onUpdate": False},
{"name": "updated", "type": "autodate", "onCreate": True, "onUpdate": True},
], "indexes": indexes, **{rule: None for rule in (
"listRule", "viewRule", "createRule", "updateRule", "deleteRule")}}
code, actual = http("GET", f"/api/collections/{name}", token=token)
if code == 404:
if dry_run:
print(f"Would create {name}")
created.append(name)
# The relations below need a collection id to point at; the
# placeholder is never written, it only keeps the dry run going.
return {"id": "000000000000000", "name": name}
code, actual = http("POST", "/api/collections", wanted, token)
if code != 200:
raise RuntimeError(f"Cannot create {name}: HTTP {code}: {actual}")
print(f"Created {name}")
created.append(name)
elif code != 200:
raise RuntimeError(f"Cannot inspect {name}: HTTP {code}")
actual = read(name)
by_name = {f["name"]: f for f in actual["fields"]}
for field in wanted["fields"]:
if any(by_name.get(field["name"], {}).get(k) != v for k, v in field.items()):
raise RuntimeError(f"Incompatible {name}.{field['name']}")
for index in indexes:
if index not in actual.get("indexes", []):
raise RuntimeError(f"Missing index on {name}")
for rule in ("listRule", "viewRule", "createRule", "updateRule", "deleteRule"):
if actual.get(rule) is not None:
raise RuntimeError(f"Expected server-only access on {name}")
if not dry_run:
print(f"Verified {name}")
return actual
sites = read("sites")
plans = ensure("management_plans", [
{"name": "plan_year", "type": "number", "required": True, "onlyInt": True},
{"name": "source_file", "type": "text", "required": False},
{"name": "source_sheet", "type": "text", "required": False},
{"name": "source_sha256", "type": "text", "required": False},
{"name": "period_label", "type": "text", "required": False},
{"name": "cost_detail_available", "type": "bool", "required": False},
{"name": "opening_retention", "type": "number", "required": False},
{"name": "reported_cost_annual", "type": "number", "required": False},
json_field("months"),
json_field("reported_income"),
json_field("cashflow_income_factors"),
json_field("forecast_cost_rates"),
json_field("reported_costs"),
json_field("reported_retention"),
json_field("reported_retention_closing"),
json_field("reported_cumulative_balance"),
json_field("retention_payouts"),
json_field("head_office_costs"),
# The retention periods of the source schedule, including the periods
# that carry no release.
json_field("retention_months"),
json_field("history"),
{"name": "revision", "type": "number", "required": False, "min": 1, "onlyInt": True},
], ["CREATE UNIQUE INDEX `idx_management_plans_plan_year` ON `management_plans` (`plan_year`)"])
projects = ensure("management_projects", [
{"name": "key", "type": "text", "required": True},
# The canonical display name, the one seen first across the years. The
# per-year client and engineer live on the project plan.
{"name": "name", "type": "text", "required": False},
relation("site", sites, False),
], ["CREATE UNIQUE INDEX `idx_management_projects_key` ON `management_projects` (`key`)"])
project_plans = ensure("management_project_plans", [
relation("plan", plans, True, True),
relation("project", projects, True),
{"name": "status", "type": "select", "required": False, "maxSelect": 1,
"values": ["active", "awarded"]},
{"name": "sort_order", "type": "number", "required": False},
# The project details of this year: they differ from year to year.
{"name": "name", "type": "text", "required": True},
{"name": "client", "type": "text", "required": False},
{"name": "engineer", "type": "text", "required": False},
{"name": "manager", "type": "text", "required": False},
json_number_field("stated_value"),
json_number_field("remaining_value"),
{"name": "retention_eligible", "type": "bool", "required": False},
json_field("income"),
json_field("forecast_income"),
json_field("actual_income"),
json_field("future_income"),
json_field("forecast_baseline"),
json_field("retention_rates"),
json_field("costs"),
{"name": "revision", "type": "number", "required": False, "min": 1, "onlyInt": True},
], ["CREATE UNIQUE INDEX `idx_management_project_plans_plan_project` ON `management_project_plans` (`plan`, `project`)"])
ensure("management_retention", [
relation("plan", plans, True, True),
{"name": "project_name", "type": "text", "required": False},
relation("project", projects, False),
{"name": "debtor", "type": "text", "required": False},
{"name": "amount", "type": "number", "required": False},
{"name": "release_date", "type": "date", "required": False},
{"name": "source_id", "type": "text", "required": False},
], ["CREATE INDEX `idx_management_retention_plan_source` ON `management_retention` (`plan`, `source_id`)"])
ensure("management_edits", [
{"name": "plan_year", "type": "number", "required": False, "onlyInt": True},
# The audit trail outlives the project plan it points at.
relation("project_plan", project_plans, False),
{"name": "project_name", "type": "text", "required": False},
{"name": "field", "type": "text", "required": False},
{"name": "month_index", "type": "number", "required": False},
json_field("old_value"),
json_field("new_value"),
{"name": "user_label", "type": "text", "required": False},
], ["CREATE INDEX `idx_management_edits_project_plan` ON `management_edits` (`project_plan`, `plan_year`)"])
if dry_run:
print(f"Dry run: {len(created)} collection(s) would be created: {', '.join(created) or 'none'}")
print("Nothing was written.")
return
print("Verified the management planning schema. No plan, project or audit record changed.")
if __name__ == "__main__":
try:
main()
except Exception as exc:
sys.exit(str(exc))