MEGAINFRA-WEBAPP/scripts/migrations/2026-09-25-add-clock-shifts.py

227 lines
10 KiB
Python

#!/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)