201 lines
No EOL
7.1 KiB
Python
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) |