diff --git a/scripts/migrations/2026-09-25-add-clock-shifts.py b/scripts/migrations/2026-09-25-add-clock-shifts.py deleted file mode 100644 index 4637157..0000000 --- a/scripts/migrations/2026-09-25-add-clock-shifts.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/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 deleted file mode 100644 index bace807..0000000 --- a/scripts/migrations/test_2026_09_25_add_clock_shifts.py +++ /dev/null @@ -1,66 +0,0 @@ -"""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 6047117..e25c31a 100644 --- a/scripts/pb-bootstrap-schema.py +++ b/scripts/pb-bootstrap-schema.py @@ -25,16 +25,13 @@ 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 = 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") +PB = "https://pb.mega.infra.apphub.co.za" +EMAIL = "admin@mega.co.za" +PASSWORD = "12345678" def http(method, path, body=None, token=None): @@ -315,62 +312,6 @@ 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. @@ -527,9 +468,6 @@ ORDER = [ "timesheets", "timesheet_entries", "clock_events", - "clock_shifts", - "clock_shift_corrections", - "clock_employee_locks", "fuel_sheets", "fuel_logs", "plant_hours_sheets", @@ -539,10 +477,7 @@ ORDER = [ "delivery_note_items", ] -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"]} +ids = {} for key in ORDER: body = dict(DEFS[key]) body.update(LOCKED) @@ -551,7 +486,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"] if other_key != "users" else "USERS" + collection_name_upper = DEFS[other_key]["name"] # e.g. "SITES" cid = cid.replace(f"__{collection_name_upper}_ID__", other_id) if "__" in cid: sys.exit(f"unresolved placeholder in {key}.{f['name']}: {cid}") @@ -573,18 +508,4 @@ for key in ORDER: print(f" ✗ {body['name']:25s} FAILED: {code} {resp}", file=sys.stderr) sys.exit(1) -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}.") +print(f"\nDone. {len(ids)} collections on {PB}.") diff --git a/scripts/pb-relax-rules.py b/scripts/pb-relax-rules.py index fcbe154..e250414 100644 --- a/scripts/pb-relax-rules.py +++ b/scripts/pb-relax-rules.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Relax legacy read/create rules without opening clock audit writes. +"""Relax API rules on the 17 MEGA INFRASTRUCTURE collections. Pattern: list/view/create → any authenticated user @@ -8,16 +8,13 @@ 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 = 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") +PB = "https://pb.mega.infra.apphub.co.za" +EMAIL = "admin@mega.co.za" +PASSWORD = "12345678" AUTH_RULE = "@request.auth.id != ''" # any logged-in user NONE_RULE = None # superuser only @@ -32,10 +29,7 @@ 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": (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), + "CLOCK_EVENTS": (AUTH_RULE, AUTH_RULE, AUTH_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),