227 lines
10 KiB
Python
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)
|