feat(clock): add locked shift schema migration

This commit is contained in:
DEls 2026-09-25 03:13:06 +02:00
parent 488eb2af99
commit 5529c3f53d
4 changed files with 389 additions and 11 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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}.")

View file

@ -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),