Kilimo Hakika Project Tracker
An internal bid pipeline and delivery tracker, reachable only over a private network
Sole Developer · Jul 2026 - Sep 2026 · Kilimo Hakika Limited

The problem
Kilimo Hakika bids for tenders, grants and expressions of interest, wins some of them, and then has to deliver. Before this, that whole pipeline lived in notebooks, chat threads and individual memory. Nobody could answer "what is in flight right now" without asking three people, and nothing recorded who moved what, or when.
The system replaces that with one auditable source of truth for roughly ten to twenty users.
One idea holding it up
Every project is a state machine, and every status change is an append-only event recording who, when, from which status, to which status, and why.
That single decision gives you the audit trail and the reporting for free, because they are the same data. The dashboard's win rate, the per-person workload, the pipeline breakdown and the "needs attention" list are all queries over the event log rather than separately maintained counters.
Eleven project statuses, four of them terminal. Six deliverable statuses. The events table refuses updates at the model level, so history cannot be quietly rewritten:
def save(self, *args, **kwargs):
if self.pk is not None:
raise TypeError("StatusEvent rows are append-only")
A reassignment is stored as an event where the status does not change, which keeps handovers in the same timeline as everything else instead of a separate log nobody reads.
Gates, because a checklist in someone's head is not a process
Moving from winning a bid to delivering it used to be informal. Now it is gated, and the gates need evidence rather than a checkbox.
Contracting to Inception requires the signed contract uploaded. Inception to In Delivery requires the inception report and at least one deliverable a project manager has approved. Closing a project successfully requires every deliverable accepted.
A project manager can override that last gate, but the override is not silent: the reason is mandatory and gets prefixed into the permanent event note, so a year later the record says exactly what was skipped and why.
Deliverables have their own flow. Staff propose, a PM approves, staff work, staff submit with mandatory evidence (a file or a link, not a claim), then the PM accepts or rejects with a reason that sends it back for rework.
Two people editing the same project
Four roles: admin, project manager, staff, and management as read-only. Staff can only touch projects assigned to them. Terminal projects are read-only for everyone, and only a super admin can reopen one, which bypasses the state machine, demands a reason, and writes an audit row.
Concurrent edits are handled with optimistic locking. Every project and deliverable carries a version number, and a save against a stale version returns a 409 with a reload-and-retry prompt rather than silently overwriting whatever the other person just did.
Authorization is enforced in the service layer, not just hidden in templates. There is a test file whose entire job is to assert that every mutating route returns 403 for the read-only management role.
Notifications without a task queue
Eight notification types: assignment, key status changes, deliverable submitted, deliverables ready for review, deadline reminders at seven, three and one day out, overdue, and stalled in contracting past thirty days.
No Celery, no Redis. A single idempotent management command runs on cron every five minutes and does both halves, sweeping for things that need notifying and sending what is pending. For an internal tool with twenty users, a task broker would have been infrastructure to maintain for no benefit.
Duplicate suppression is a unique key on the notification row plus an insert that ignores conflicts, so the sweep can run as often as it likes without ever sending the same reminder twice. Delivery is email plus web push; a push endpoint returning 410 Gone deletes the dead subscription automatically.
The system also declines to tell you about your own actions, which sounds trivial and is the difference between people reading notifications and muting them.
Not on the internet at all
The tracker holds commercial pipeline data, so it is not publicly reachable. It sits behind a Headscale network (self-hosted Tailscale) and only devices joined to it can reach the app at all. The VPN coordination server is the single publicly resolvable name; the tracker itself is not.
Rolling out the access-control policy needed care. Applying restrictive rules before every device is tagged drops the entire fleet off the network at once, so it went out in three phases: declare the tags permissively, tag every node, then tighten. One genuinely nasty detail cost me time and is now written down in the runbook: sending Headscale a reload signal re-reads the policy file but not the config file, so the very first time you point it at a policy path, the reload appears to succeed while loading nothing at all.
Uploads get the same suspicion as everything else. Twenty megabyte cap, extension checks, and magic-byte sniffing of the actual file contents. Files are stored under generated names, and downloads go through Django for an authorization check before the reverse proxy serves the bytes.
Testing
268 test functions across 23 files, concentrated on the state machine, the permission matrix, the gates and the optimistic locking. Those are the parts where a bug corrupts the record rather than just rendering something wrong.
Honest status
Nightly Postgres dumps and media snapshots run with thirty-day retention, but the off-server copy is not configured yet, so backups currently live only on the same VPS as the thing they are backing up. That is the next job and I have not pretended otherwise in the runbook.
The search box in the top bar is decorative. It is a styled div with no input behind it, left in place while the rest shipped.