feat(clock): add locked shift schema migration
This commit is contained in:
parent
488eb2af99
commit
5529c3f53d
4 changed files with 389 additions and 11 deletions
227
scripts/migrations/2026-09-25-add-clock-shifts.py
Normal file
227
scripts/migrations/2026-09-25-add-clock-shifts.py
Normal 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)
|
||||
66
scripts/migrations/test_2026_09_25_add_clock_shifts.py
Normal file
66
scripts/migrations/test_2026_09_25_add_clock_shifts.py
Normal 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()
|
||||
|
|
@ -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}.")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue