MEGAINFRA-WEBAPP/scripts/migrations/2026-09-12-add-clock-events.py

201 lines
No EOL
7.1 KiB
Python

#!/usr/bin/env python3
"""
Create the `clock_events` collection — the raw audit log behind the
MEGA Clock timekeeping PWA.
See docs/superpowers/specs/2026-09-11-mega-clock-timeclock-pwa-design.md.
PB_URL=... PB_ADMIN_EMAIL=... PB_ADMIN_PASSWORD=... \\
python3 scripts/migrations/2026-09-12-add-clock-events.py [--apply]
DRY RUN BY DEFAULT.
Every scan (in or out) writes one row here, independent of the derived
daily totals in `timesheet_entries`. `employee`/`site`/`timesheet_entry`
are all cascadeDelete=False, same posture as every other relation in
this schema: deleting the parent must not silently destroy the audit
trail that references it.
Created LOCKED (superuser-only on every rule), matching a fresh
`pb-bootstrap-schema.py` run — run `pb-relax-rules.py` afterward (it now
includes CLOCK_EVENTS) to open list/view/create to any authenticated
user, same as every sibling collection.
Idempotent: skips if the collection already exists.
"""
import json
import os
import sys
import urllib.error
import urllib.request
APPLY = "--apply" in sys.argv
LOCKED = {
"listRule": None, "viewRule": None,
"createRule": None, "updateRule": None, "deleteRule": None,
}
def field(name, type_, **kw):
f = {"name": name, "type": type_}
f.update(kw)
return f
def clock_events_fields(employees_id, sites_id, timesheet_entries_id):
return [
field("employee", "relation", required=True,
collectionId=employees_id, cascadeDelete=False, maxSelect=1, minSelect=1),
field("site", "relation", required=True,
collectionId=sites_id, cascadeDelete=False, maxSelect=1, minSelect=1),
field("direction", "select", required=True,
values=["in", "out"], maxSelect=1),
field("scanned_at", "date", required=True),
field("timesheet_entry", "relation", required=False,
collectionId=timesheet_entries_id, cascadeDelete=False, maxSelect=1),
]
INDEXES = [
"CREATE INDEX `idx_clock_events_emp_scanned` ON `clock_events` (`employee`, `scanned_at`)",
]
def admin_auth(base_url):
body = json.dumps({
"identity": os.environ["PB_ADMIN_EMAIL"],
"password": os.environ["PB_ADMIN_PASSWORD"],
}).encode()
req = urllib.request.Request(
f"{base_url}/api/collections/_superusers/auth-with-password",
data=body, headers={"Content-Type": "application/json"}, method="POST",
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())["token"]
def api(base_url, token, method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{base_url}{path}", data=data, method=method,
headers={"Authorization": token, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req) as resp:
txt = resp.read().decode()
return json.loads(txt) if txt else None
except urllib.error.HTTPError as e:
if e.code == 404 and method == "GET":
return None
raise RuntimeError(
f"{method} {path}: {e.code} {e.read().decode() if e.fp else ''}"
) from e
def verify(collection, employees_id, sites_id, timesheet_entries_id):
problems = []
fields = {f["name"]: f for f in collection["fields"]}
expected = {f["name"] for f in clock_events_fields(employees_id, sites_id, timesheet_entries_id)}
missing = sorted(expected - set(fields))
if missing:
problems.append(f"missing fields: {', '.join(missing)}")
for rel_name, expected_id in (
("employee", employees_id),
("site", sites_id),
("timesheet_entry", timesheet_entries_id),
):
f = fields.get(rel_name)
if f is None:
continue
if f.get("cascadeDelete") is not False:
problems.append(f"{rel_name}.cascadeDelete is {f.get('cascadeDelete')!r}, must be False")
if f.get("collectionId") != expected_id:
problems.append(
f"{rel_name}.collectionId is {f.get('collectionId')!r}, expected {expected_id!r}"
)
direction = fields.get("direction")
if direction is None or set(direction.get("values") or []) != {"in", "out"}:
got = direction.get("values") if direction else None
problems.append(f"direction.values is {got!r}, expected ['in', 'out']")
for rule, want in LOCKED.items():
if collection.get(rule) != want:
problems.append(f"{rule} is {collection.get(rule)!r}, expected {want!r}")
landed = collection.get("indexes") or []
# PB normalizes collection names to upper-case and reformats the
# CREATE INDEX statement (splits the column list across lines), so
# do a token-level comparison instead of an exact string match.
def normalize(sql: str) -> str:
return " ".join(sql.replace("\n", " ").split()).lower()
landed_norm = {normalize(s) for s in landed}
for want in INDEXES:
if normalize(want) not in landed_norm:
problems.append(f"index missing: {want}")
return problems
def main():
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
token = admin_auth(base_url)
print(f"target: {base_url} mode: {'APPLY' if APPLY else 'DRY RUN'}")
existing = api(base_url, token, "GET", "/api/collections/clock_events")
if existing is not None:
print(f"\nSKIP clock_events: already exists (id={existing['id']})")
return 0
ids = {}
for name in ("employees", "sites", "timesheet_entries"):
col = api(base_url, token, "GET", f"/api/collections/{name}")
if col is None:
print(f"\nABORT: collection `{name}` not found - cannot build the relation")
return 1
ids[name] = col["id"]
print(f"{name} collection id: {ids[name]}")
print("\nCREATE clock_events")
if not APPLY:
print("\nDRY RUN. Re-run with --apply to create.")
return 0
created = api(
base_url, token, "POST", "/api/collections",
{
"name": "CLOCK_EVENTS",
"type": "base",
"fields": clock_events_fields(ids["employees"], ids["sites"], ids["timesheet_entries"]),
**LOCKED,
"indexes": INDEXES,
},
)
if not created or not created.get("id"):
print(" FAILED: create returned no collection")
return 1
print(f" created id={created['id']}")
after = api(base_url, token, "GET", f"/api/collections/{created['id']}")
if after is None:
print(f" FAILED: collection id={created['id']} vanished after create")
return 1
problems = verify(after, ids["employees"], ids["sites"], ids["timesheet_entries"])
if problems:
for p in problems:
print(f" FAILED: {p}")
return 1
print(" OK cascadeDelete=False on all relations, LOCKED rules, index present")
print("\nNow run scripts/pb-relax-rules.py to open list/view/create to authenticated users.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyError as e:
print(f"ERROR: missing environment variable {e}", file=sys.stderr)
sys.exit(2)