Skip to content

Developer guide

abbas0444 edited this page Sep 9, 2026 · 2 revisions

Developer guide

Layout

nexus_zkt_integration/
├── hooks.py                       app metadata, the two scheduled jobs, /apps tile
├── api.py                         has_app_permission for the /apps tile
├── install.py  uninstall.py
└── nexus_biometric_attendance/
    ├── punch.py                   the rules. imports nothing from Frappe
    ├── reader.py                  everything that speaks the ZK protocol
    ├── journal.py                 the activity log, and the live progress bar
    ├── collector.py               one run: read every machine, then write
    ├── api.py                     the four whitelisted methods, and the hourly job
    ├── doctype/
    │   ├── nexus_zkt_settings/    the Single that holds the device list
    │   ├── nexus_zkt_device/      one row in it
    │   └── nexus_attendance_log/  what a run wrote down
    └── workspace/nexus_zkt/

The split is deliberate. punch.py holds the only part worth reasoning about carefully — whether a punch is an arrival or a departure — and it has no database, no socket and no Frappe import, so it can be tested on its own and read in one sitting.

The shape of a run

api.collect_now(force=1)          the button, and bench execute
api.scheduled_collection()        the hourly job; steps aside if one is running
        │
        └── Collector.collect()
              ├── _read_every_device()   one Take per machine that answered
              └── _record()              every punch, merged, oldest first

_read_every_device returns a Take per machine — the device row, when it was read, its punches, and a Tally. _record then merges every take's punches into one time-ordered stream before deciding anything, which is what makes more than one door work. Each punch is still judged by its own machine's DirectionRule.

Entry points

Method Whitelisted Used by
api.collect_now(force=0) yes Sync Attendance Now, bench execute
api.sync_status() yes the settings form, polling
api.purge_journal() yes Clear Logs, and the weekly job
api.erase_device(device_id) yes Clear Device Memory
api.scheduled_collection() no hourly_long in hooks.py

Two framework versions

version-16 is the default branch; version-15 sits beside it, not behind it. Neither is ahead of the other, so GitHub does not offer a pull request between them. Only four files differ, and none of them is Python: the README banner, the CI matrix, a comment in pyproject.toml, and the workspace JSON — Frappe 15's Workspace DocType has no app field.

Two Frappe differences are worth knowing if you touch this code:

  • frappe.cache is a module attribute on both, not a function.
  • Frappe 15 caches a cache miss. get_value(key) stores what it found in frappe.local.cache even when that is nothing, while set_value(key, val, expires_in_sec=...) writes only to Redis and never refreshes that local copy. Read the key once before the first write and None is pinned for the rest of the request. Every read of the run status therefore goes through journal.read_status(), which passes expires=True. This is invisible on Frappe 16, which is exactly why both branches are tested.

Tests

bench --site your-site set-config allow_tests true
bench --site your-site run-tests --app nexus_zkt_integration

106 tests. Twenty-seven of them are plain unittest and need no site, because punch.py needs none.

File Covers
test_punch_rules.py arrival/departure rules, the repeat window, the open-shift limit
test_many_doors.py merging across machines, fixed-direction doors, ports
test_collector.py shift mapping, the wait between reads, the journal, run status
test_settings.py duplicate names, duplicate machines, the password field
test_app_wiring.py every dotted path in hooks.py, every method the client calls

CI runs the whole suite against frappe + erpnext + hrms at both 15 and 16 on every push, and asserts frappe.__version__ really starts with the number the branch claims — a bench init without --frappe-branch quietly builds develop.

Testing without a device

Replace the reader. It is one function:

from nexus_zkt_integration.nexus_biometric_attendance import collector
from nexus_zkt_integration.nexus_biometric_attendance.punch import Punch

def fake(device, on_warning=None):
    return [Punch("101", datetime(2026, 4, 6, 9, 0), 255)]

collector.read_punches = fake

read_punches(device, on_warning=None) must return a list of Punch, and is called once per device, so a dict keyed by device.device_id gives each machine its own answer.

House style

ruff for Python, prettier for JavaScript, both wired into .pre-commit-config.yaml:

pre-commit install
pre-commit run --all-files

Tabs, double quotes, 110 columns.

Clone this wiki locally