diff --git a/scripts/migrations/2026-09-25-add-clock-shifts.py b/scripts/migrations/2026-09-25-add-clock-shifts.py new file mode 100644 index 0000000..4637157 --- /dev/null +++ b/scripts/migrations/2026-09-25-add-clock-shifts.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Add locked clock shift collections and unassigned clock account fields. + +By default this prints the intended schema. With PB_URL, PB_ADMIN_EMAIL and +PB_ADMIN_PASSWORD set it also compares the live schema using read-only calls. +Only --apply performs schema writes; it requires all three variables. +""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +def field(name, type_, **options): + return {"name": name, "type": type_, **options} + + +def relation(name, collection_id, required=False, max_select=1): + return field(name, "relation", required=required, collectionId=collection_id, + cascadeDelete=False, maxSelect=max_select, + **({"minSelect": 1} if required else {})) + + +def locked_rules(): + return {key: None for key in + ("listRule", "viewRule", "createRule", "updateRule", "deleteRule")} + + +def shift_indexes(): + return [ + "CREATE UNIQUE INDEX `idx_clock_shift_in` ON `clock_shifts` (`clock_in_event`)", + "CREATE INDEX `idx_clock_shift_emp_day` ON `clock_shifts` (`employee`, `local_work_date`)", + "CREATE INDEX `idx_clock_shift_open` ON `clock_shifts` (`clock_out_at`, `local_work_date`)", + ] + + +def correction_indexes(): + return ["CREATE UNIQUE INDEX `idx_clock_correction_shift` ON `clock_shift_corrections` (`shift`)"] + + +def lock_indexes(): + return ["CREATE UNIQUE INDEX `idx_clock_lock_employee` ON `clock_employee_locks` (`employee`)"] + + +def user_role_field(): + return field("role", "select", required=False, + values=["kiosk", "supervisor"], maxSelect=1) + + +def user_sites_field(sites_id="__SITES_ID__"): + return relation("allowed_sites", sites_id, max_select=100) + + +def collection_definitions(ids): + shifts = { + "name": "clock_shifts", "type": "base", + "fields": [ + relation("employee", ids["employees"], required=True), + relation("site", ids["sites"], required=True), + relation("clock_in_event", ids["clock_events"], required=True), + field("clock_in_at", "date", required=True), + field("local_work_date", "text", required=True, min=10, max=10), + relation("clock_out_event", ids["clock_events"]), + field("clock_out_at", "date", required=False), + field("close_source", "select", required=False, + values=["scan", "supervisor"], maxSelect=1), + relation("timesheet_entry", ids["timesheet_entries"]), + field("payroll_status", "select", required=True, + values=["open", "pending", "synced", "conflict"], maxSelect=1), + ], + "indexes": shift_indexes(), **locked_rules(), + } + corrections = { + "name": "clock_shift_corrections", "type": "base", + "fields": [ + relation("shift", ids.get("clock_shifts", "__CLOCK_SHIFTS_ID__"), required=True), + field("actual_out_at", "date", required=True), + field("reason", "text", required=True, min=1, max=2000), + relation("supervisor_user", ids["users"], required=True), + field("recorded_at", "date", required=True), + ], + "indexes": correction_indexes(), **locked_rules(), + } + locks = { + "name": "clock_employee_locks", "type": "base", + "fields": [ + relation("employee", ids["employees"], required=True), + field("owner", "text", required=True, min=1, max=128), + ], + "indexes": lock_indexes(), **locked_rules(), + } + return {name: definition for name, definition in + (("clock_shifts", shifts), ("clock_shift_corrections", corrections), + ("clock_employee_locks", locks))} + + +def api(base_url, token, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(base_url + path, data=data, method=method, + headers={"Content-Type": "application/json", + "Authorization": token}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + content = response.read() + return json.loads(content) if content else None + except urllib.error.HTTPError as error: + if error.code == 404 and method == "GET": + return None + # Do not print server response bodies: some may contain supplied values. + raise RuntimeError(f"{method} {path} failed ({error.code})") from None + + +def authenticate(base_url): + request = urllib.request.Request( + base_url + "/api/collections/_superusers/auth-with-password", + data=json.dumps({"identity": os.environ["PB_ADMIN_EMAIL"], + "password": os.environ["PB_ADMIN_PASSWORD"]}).encode(), + method="POST", headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.load(response)["token"] + except urllib.error.HTTPError as error: + raise RuntimeError(f"authentication failed ({error.code})") from None + + +def normalize_index(index): + return " ".join(index.lower().replace("`", "").split()) + + +def compare_collection(actual, wanted): + """Return a safe additive patch or fail if an existing field differs.""" + if actual.get("type") != wanted["type"]: + raise ValueError(f"{wanted['name']}: collection type differs") + fields = {item["name"]: item for item in actual.get("fields", [])} + missing = [] + for expected in wanted["fields"]: + existing = fields.get(expected["name"]) + if existing is None: + missing.append(expected) + elif any(existing.get(key) != value for key, value in expected.items()): + raise ValueError(f"{wanted['name']}.{expected['name']}: existing field differs") + indexes = {normalize_index(index) for index in actual.get("indexes", [])} + new_indexes = [item for item in wanted["indexes"] + if normalize_index(item) not in indexes] + patch = {} + if missing: + patch["fields"] = actual["fields"] + missing + if new_indexes: + patch["indexes"] = actual.get("indexes", []) + new_indexes + for rule, expected in locked_rules().items(): + if actual.get(rule) != expected: + patch[rule] = expected + return patch, [item["name"] for item in missing], len(new_indexes) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", help="write the additive schema") + args = parser.parse_args() + credentials = ("PB_URL", "PB_ADMIN_EMAIL", "PB_ADMIN_PASSWORD") + supplied = [key for key in credentials if os.environ.get(key)] + if args.apply and len(supplied) != len(credentials): + parser.error("--apply requires PB_URL, PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD") + if supplied and len(supplied) != len(credentials): + parser.error("set all three PB variables for a live comparison, or none for an offline plan") + + online = len(supplied) == len(credentials) + base_url = os.environ["PB_URL"].rstrip("/") if online else "" + token = authenticate(base_url) if online else "" + print(f"Mode: {'APPLY' if args.apply else 'DRY RUN'}; target: {'configured PocketBase' if online else 'offline schema plan'}") + names = ("employees", "sites", "clock_events", "timesheet_entries", "users") + existing = {name: api(base_url, token, "GET", f"/api/collections/{name}") + for name in names} if online else {} + if online and any(existing[name] is None for name in names): + raise RuntimeError("required collection missing: " + + ", ".join(name for name in names if existing[name] is None)) + ids = {name: existing[name]["id"] if online else "__" + name.upper() + "_ID__" + for name in names} + + for name in ("clock_shifts", "clock_shift_corrections", "clock_employee_locks"): + actual = api(base_url, token, "GET", f"/api/collections/{name}") if online else None + if name == "clock_shift_corrections": + if online: + shift = api(base_url, token, "GET", "/api/collections/clock_shifts") + if shift is None and args.apply: + raise RuntimeError("clock_shifts must be created before corrections") + ids["clock_shifts"] = shift["id"] if shift else "__CLOCK_SHIFTS_ID__" + else: + ids["clock_shifts"] = "__CLOCK_SHIFTS_ID__" + definition = collection_definitions(ids)[name] + if actual is None: + print(f"CREATE {name}: {len(definition['fields'])} fields, {len(definition['indexes'])} indexes; locked rules") + if args.apply: + created = api(base_url, token, "POST", "/api/collections", definition) + ids[name] = created["id"] + else: + patch, missing, index_count = compare_collection(actual, definition) + print(f"{name}: add fields {missing}, indexes {index_count}, lock rules {bool(set(patch) & set(locked_rules()))}") + if args.apply and patch: + api(base_url, token, "PATCH", f"/api/collections/{actual['id']}", patch) + ids[name] = actual["id"] + + users = existing.get("users") + if users: + wanted = {"name": "users", "type": users["type"], + "fields": [user_role_field(), user_sites_field(ids["sites"])], + "indexes": users.get("indexes", []), + **{key: users.get(key) for key in locked_rules()}} + patch, missing, _ = compare_collection(users, wanted) + print(f"users: add fields {missing}; no user roles or site assignments changed") + if args.apply and patch: + api(base_url, token, "PATCH", f"/api/collections/{users['id']}", patch) + else: + print("users: add optional role and allowed_sites fields; no account assignments") + print("No records changed." if not args.apply else "Schema applied; verify collections before enabling the app.") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (RuntimeError, ValueError) as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/migrations/test_2026_09_25_add_clock_shifts.py b/scripts/migrations/test_2026_09_25_add_clock_shifts.py new file mode 100644 index 0000000..bace807 --- /dev/null +++ b/scripts/migrations/test_2026_09_25_add_clock_shifts.py @@ -0,0 +1,66 @@ +"""No live PocketBase calls: exercise the migration's schema builders.""" +import importlib.util +from pathlib import Path +import unittest + + +SCRIPT = Path(__file__).with_name("2026-09-25-add-clock-shifts.py") +spec = importlib.util.spec_from_file_location("clock_shift_schema", SCRIPT) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class SchemaTests(unittest.TestCase): + def test_clock_records_are_locked_and_uniquely_linked(self): + self.assertEqual(module.locked_rules(), { + "listRule": None, "viewRule": None, "createRule": None, + "updateRule": None, "deleteRule": None, + }) + self.assertIn("UNIQUE INDEX", " ".join(module.shift_indexes()).upper()) + self.assertIn("clock_in_event", " ".join(module.shift_indexes())) + self.assertIn("UNIQUE INDEX", " ".join(module.correction_indexes()).upper()) + self.assertIn("`shift`", " ".join(module.correction_indexes())) + self.assertIn("UNIQUE INDEX", " ".join(module.lock_indexes()).upper()) + self.assertIn("`employee`", " ".join(module.lock_indexes())) + + def test_roles_are_declared_but_no_existing_user_is_promoted(self): + self.assertEqual(module.user_role_field()["values"], ["kiosk", "supervisor"]) + self.assertFalse(module.user_role_field()["required"]) + self.assertEqual(module.user_sites_field()["type"], "relation") + self.assertFalse(module.user_sites_field()["required"]) + + def test_all_new_relations_preserve_audit_history(self): + ids = {name: name + "id" for name in + ("employees", "sites", "clock_events", "timesheet_entries", "clock_shifts", "users")} + definitions = module.collection_definitions(ids) + self.assertEqual(set(definitions), {"clock_shifts", "clock_shift_corrections", "clock_employee_locks"}) + for definition in definitions.values(): + for field in definition["fields"]: + if field["type"] == "relation": + self.assertFalse(field["cascadeDelete"]) + self.assertEqual(definition["createRule"], None) + + def test_additive_comparison_keeps_existing_fields_and_closes_open_rules(self): + existing = { + "name": "clock_employee_locks", "type": "base", + "fields": [{"name": "legacy", "type": "text"}], + "indexes": [], "createRule": "@request.auth.id != ''", + } + desired = { + "name": "clock_employee_locks", "type": "base", + "fields": [{"name": "employee", "type": "relation"}], + "indexes": module.lock_indexes(), **module.locked_rules(), + } + patch, missing, index_count = module.compare_collection(existing, desired) + self.assertEqual(missing, ["employee"]) + self.assertEqual(index_count, 1) + self.assertEqual(patch["fields"][0]["name"], "legacy") + self.assertIsNone(patch["createRule"]) + + existing["fields"].append({"name": "employee", "type": "text"}) + with self.assertRaises(ValueError): + module.compare_collection(existing, desired) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/pb-bootstrap-schema.py b/scripts/pb-bootstrap-schema.py index e25c31a..6047117 100644 --- a/scripts/pb-bootstrap-schema.py +++ b/scripts/pb-bootstrap-schema.py @@ -25,13 +25,16 @@ Reflects the post-ingestion-restructure schema (2026-09): were missing from this bootstrap script; both are included now. """ import json +import os import sys import urllib.request import urllib.error -PB = "https://pb.mega.infra.apphub.co.za" -EMAIL = "admin@mega.co.za" -PASSWORD = "12345678" +PB = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/") +EMAIL = os.environ.get("PB_ADMIN_EMAIL") +PASSWORD = os.environ.get("PB_ADMIN_PASSWORD") +if not EMAIL or not PASSWORD: + sys.exit("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD for schema bootstrap") def http(method, path, body=None, token=None): @@ -312,6 +315,62 @@ DEFS["clock_events"] = { ], } +DEFS["clock_shifts"] = { + "name": "clock_shifts", "type": "base", + "fields": [ + 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("clock_in_event", "relation", required=True, + collectionId="__CLOCK_EVENTS_ID__", cascadeDelete=False, maxSelect=1, minSelect=1), + field("clock_in_at", "date", required=True), + field("local_work_date", "text", required=True, min=10, max=10), + field("clock_out_event", "relation", required=False, + collectionId="__CLOCK_EVENTS_ID__", cascadeDelete=False, maxSelect=1), + field("clock_out_at", "date", required=False), + field("close_source", "select", required=False, + values=["scan", "supervisor"], maxSelect=1), + field("timesheet_entry", "relation", required=False, + collectionId="__TIMESHEET_ENTRIES_ID__", cascadeDelete=False, maxSelect=1), + field("payroll_status", "select", required=True, + values=["open", "pending", "synced", "conflict"], maxSelect=1), + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_clock_shift_in` ON `clock_shifts` (`clock_in_event`)", + "CREATE INDEX `idx_clock_shift_emp_day` ON `clock_shifts` (`employee`, `local_work_date`)", + "CREATE INDEX `idx_clock_shift_open` ON `clock_shifts` (`clock_out_at`, `local_work_date`)", + ], +} + +DEFS["clock_shift_corrections"] = { + "name": "clock_shift_corrections", "type": "base", + "fields": [ + field("shift", "relation", required=True, + collectionId="__CLOCK_SHIFTS_ID__", cascadeDelete=False, maxSelect=1, minSelect=1), + field("actual_out_at", "date", required=True), + field("reason", "text", required=True, min=1, max=2000), + field("supervisor_user", "relation", required=True, + collectionId="__USERS_ID__", cascadeDelete=False, maxSelect=1, minSelect=1), + field("recorded_at", "date", required=True), + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_clock_correction_shift` ON `clock_shift_corrections` (`shift`)", + ], +} + +DEFS["clock_employee_locks"] = { + "name": "clock_employee_locks", "type": "base", + "fields": [ + field("employee", "relation", required=True, + collectionId="__EMPLOYEES_ID__", cascadeDelete=False, maxSelect=1, minSelect=1), + field("owner", "text", required=True, min=1, max=128), + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_clock_lock_employee` ON `clock_employee_locks` (`employee`)", + ], +} + DEFS["fuel_sheets"] = { "name": "FUEL_SHEETS", "type": "base", # site is optional: a pump sheet can span sites, resolved per-row. @@ -468,6 +527,9 @@ ORDER = [ "timesheets", "timesheet_entries", "clock_events", + "clock_shifts", + "clock_shift_corrections", + "clock_employee_locks", "fuel_sheets", "fuel_logs", "plant_hours_sheets", @@ -477,7 +539,10 @@ ORDER = [ "delivery_note_items", ] -ids = {} +code, users = http("GET", "/api/collections/users", token=TOKEN) +if code != 200: + sys.exit("users auth collection is required for clock shift corrections") +ids = {"users": users["id"]} for key in ORDER: body = dict(DEFS[key]) body.update(LOCKED) @@ -486,7 +551,7 @@ for key in ORDER: cid = f.get("collectionId", "") # placeholders use collection table name in upper, e.g. __SITES_ID__ for other_key, other_id in ids.items(): - collection_name_upper = DEFS[other_key]["name"] # e.g. "SITES" + collection_name_upper = DEFS[other_key]["name"] if other_key != "users" else "USERS" cid = cid.replace(f"__{collection_name_upper}_ID__", other_id) if "__" in cid: sys.exit(f"unresolved placeholder in {key}.{f['name']}: {cid}") @@ -508,4 +573,18 @@ for key in ORDER: print(f" ✗ {body['name']:25s} FAILED: {code} {resp}", file=sys.stderr) sys.exit(1) -print(f"\nDone. {len(ids)} collections on {PB}.") +user_fields = list(users["fields"]) +existing_user_fields = {f["name"] for f in user_fields} +if "role" not in existing_user_fields: + user_fields.append(field("role", "select", required=False, + values=["kiosk", "supervisor"], maxSelect=1)) +if "allowed_sites" not in existing_user_fields: + user_fields.append(field("allowed_sites", "relation", required=False, + collectionId=ids["sites"], cascadeDelete=False, maxSelect=100)) +if len(user_fields) != len(users["fields"]): + code, response = http("PATCH", f"/api/collections/{users['id']}", + {"fields": user_fields}, TOKEN) + if code != 200: + sys.exit(f"users clock fields update failed: {code}") + +print(f"\nDone. {len(ORDER)} application collections on {PB}.") diff --git a/scripts/pb-relax-rules.py b/scripts/pb-relax-rules.py index e250414..fcbe154 100644 --- a/scripts/pb-relax-rules.py +++ b/scripts/pb-relax-rules.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Relax API rules on the 17 MEGA INFRASTRUCTURE collections. +"""Relax legacy read/create rules without opening clock audit writes. Pattern: list/view/create → any authenticated user @@ -8,13 +8,16 @@ Pattern: Override per-collection with the RULES dict if you need different behaviour. """ import json +import os import sys import urllib.request import urllib.error -PB = "https://pb.mega.infra.apphub.co.za" -EMAIL = "admin@mega.co.za" -PASSWORD = "12345678" +PB = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/") +EMAIL = os.environ.get("PB_ADMIN_EMAIL") +PASSWORD = os.environ.get("PB_ADMIN_PASSWORD") +if not EMAIL or not PASSWORD: + sys.exit("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD before updating collection rules") AUTH_RULE = "@request.auth.id != ''" # any logged-in user NONE_RULE = None # superuser only @@ -29,7 +32,10 @@ RULES = { "FUEL_PRICES": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE), "TIMESHEETS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE), "TIMESHEET_ENTRIES": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE), - "CLOCK_EVENTS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE), + "CLOCK_EVENTS": (NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE), + "clock_shifts": (NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE), + "clock_shift_corrections": (NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE), + "clock_employee_locks": (NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE, NONE_RULE), "FUEL_SHEETS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE), "FUEL_LOGS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE), "PLANT_HOURS_SHEETS": (AUTH_RULE, AUTH_RULE, AUTH_RULE, NONE_RULE, NONE_RULE),