Case study · Khonaini International Company Ltd. · Jul 2023 – present
The system that knows who has the laptop.
An internal platform for IT asset custody: what the company owns, who is holding it right now, and who signed for it. Django and PostgreSQL, replacing a legacy product and the spreadsheets several departments were keeping alongside it.
The repository is private: it holds employee records, government ID numbers and scanned signatures. So this page is the design rather than the code: the schema, how a custody transfer is written, the two decisions I would defend, and the one I would reverse.
Two systems of record held the same facts. A legacy application had part of the asset inventory; spreadsheets held the rest, plus everything the legacy product could not represent, which included the only thing anyone actually needed to look up, namely who currently has a given machine.
The consequences were the predictable ones. The same asset could exist twice under two labels. An employee could leave without their equipment being returned in any record. And when a laptop went missing, there was no document with a signature on it saying who had accepted responsibility for it.
That last point is what shaped the whole design. This is not really an inventory system. It is a custody ledger, and its job is to be able to prove a chain of possession after the fact.
Six tables. The interesting one is ASSETASSIGNMENT,
which is not a many-to-many join but an event with its own
attributes: when it started, when it ended, and two signature
images. An asset with no open assignment is in the store. An asset
with one is in someone's hands.
on_delete annotations are the design: everything that
carries custody history is PROTECT, everything
organisational is SET_NULL. A null
returned_at means the assignment is open. Rows are
closed, never deleted, so the custody history of any asset is one
query.
Why half the foreign keys refuse to delete
Asset.brand, Asset.category,
AssetAssignment.asset and
AssetAssignment.employee are all
on_delete=PROTECT. Employee.project and
AssetAssignment.project are SET_NULL.
That split is deliberate, and it is the decision I would defend
first.
You cannot delete an employee who has ever signed for a machine.
Their signature is the evidence, and keeping it is the whole
reason the system exists. So an employee who
leaves gets is_active = False; 23 of the 933 records are flagged
inactive, and none have been removed. Projects are different: a project
ending is normal, and it should not take an asset's history with it,
so those references go null and the assignment survives.
The practical consequence is that the app has almost no delete paths, and the database enforces that rather than trusting the application to remember. That was the point of leaving the spreadsheets: a rule nobody can accidentally skip.
One asset should never be open to two people at once. That is the core invariant, and my first attempt to enforce it was wrong in a way worth writing down.
The initial migration declared:
The intent was: an asset can only have one row where
returned_at is null. The problem is that this does not
work, and it does not work for a reason that is easy to say and easy
to forget. in SQL, null is not equal to
null. A unique index treats every null as distinct, so
(asset 42, null) and
(asset 42, null) do not collide. The constraint
permitted precisely the situation it was written to forbid, while
still being able to fire on legitimate rows that happened to share a
return timestamp.
The right tool is a partial unique index, a
uniqueness rule that applies to a filtered subset of rows. In
PostgreSQL that is CREATE UNIQUE INDEX … WHERE returned_at IS
NULL; in Django it is:
To be straight about the current state: that constraint is not in the database yet. Between migration 0007 and now, the invariant has been enforced by the application flow alone. The issue path looks for an open assignment and closes it before creating the next one, which works right up until two requests do it at the same time. It is the next change I am making, and it goes in alongside the transaction fix in the known gaps below.
The migration will apply cleanly. I checked all 428 open assignments and no asset is open more than once, so there is nothing to reconcile first. I would not call that safe, though. One person using an internal tool on a LAN very rarely races against themselves.
Issuing assets happens through a wizard: pick the employee, pick one or more assets, both parties sign on screen, confirm. The confirm step is where all the rules live.
Two signatures, and a Django wrinkle
Every assignment carries two images: the employee accepting the
equipment and an authority releasing it. Neither is optional, both
are captured on a signature pad in the browser, and a separate
view_signatures permission controls who can look at
them afterwards. They are specimen signatures of real people.
Storing them exposed a small gap in Django. An
upload_to callable receives the model instance and the
filename, but not which field triggered it, so with
two ImageFields on one model it cannot tell an employee
signature from an authority signature, and both would land on the
same path. My workaround is to set a temporary attribute on the
instance before each save, read it inside the callable to choose the
filename, and delete it afterwards:
Server-rendered Django templates. No SPA, no build step, no JavaScript framework. The only real JavaScript is the signature pad and the modal that hosts the wizards. Every sidebar entry is gated on a permission, so two accounts in different groups see different navigation.
| # ⇅ | Asset id ⇅ | Name ⇅ | S/N ⇅ | Brand ⇅ | Model ⇅ | Status | Emp no |
|---|---|---|---|---|---|---|---|
| 1 | 0412 | Desktop | XXXXXXX | Brand A | Model 1000 | Available | – |
| 2 | 0413 | Laptop | XXXXXXX | Brand B | Model 2000 | Assigned | 0091 |
| 3 | 0414 | Monitor 24" | XXXXXXX | Brand A | Model 24H | Assigned | 0143 |
| 4 | 0415 | Printer | XXXXXXX | Brand C | Model 7740 | Available | – |
Two management commands, because a custody ledger that loses its
history is worse than no ledger. backup_core writes
three artifacts; restore_core puts them back, and each
part can be skipped independently with
--no-src, --no-db or
--no-signatures.
--exclude flags are there
because of a failed restore.
dumpdata serialises contenttypes and
auth.Permission, then loaddata collides
with the rows Django's own migrations have already created in the
fresh database. Excluding both, and using natural keys so foreign
keys survive different primary key sequences, is what makes the
round-trip actually work.
I know that because the first restore failed on an integrity error, and it took a while to work out why. It also means the honest answer to "have you ever restored a backup" is yes, more than once, and I know which flags matter.
change
1. model should have been a table
brand and category are foreign keys to
reference tables. model is a free-text
CharField. At the time that looked like pragmatism.
There are thousands of hardware models and I did not want to
maintain a catalogue of them. What it actually bought was this:
OPTIPLEX-7000, OptiPlex 7000,
Optiplex7000, OPTI-PLEX 7000,
OPTIPOLEX 7000 and Optiolex 7000 tower.
Most arrived with the legacy spreadsheet import and the rest were
typed in afterwards, because nothing stopped them.
The practical damage is that you cannot answer "how many OptiPlex
7000s do we have" with a GROUP BY. That is one of the
questions the system was built for. Grouping by
model returns 255 groups for maybe thirty real
machines.
Auditing it turned up a second, uglier class of defect: with no validation on a free-text column, values end up in the wrong column entirely.
CharField with no validator accepts anything, and 684
rows later you find out what "anything" meant.
So the same fix has a second half: validators and format checks on
the columns that have a known shape (national ID, mobile
number, serial number), enforced at the database level with
CheckConstraint rather than only in a form, so the next
bulk import cannot route around them the way the last one did.
The fix is a Model table with a foreign key to
Brand, and a data migration to collapse the existing
strings into it. That migration is the interesting part: exact
matching after normalisation gets from 255 to 183, and the remaining
gap needs fuzzy clustering: PostgreSQL's
pg_trgm similarity over the normalised strings, with
the ambiguous clusters reviewed by hand. It
is a day of work I should have spent before importing 684 rows
instead of after.
2. The custom user model, added and then removed
Migration 0013 introduced a custom User extending
AbstractUser, with edit_users and
view_users_readonly permissions. Migration 0014 deleted
it again.
The lesson is a Django-specific one that is well documented and that
I learned anyway: swapping AUTH_USER_MODEL is
effectively a decision you make before the first migration, not
after thirteen. Once other tables have foreign keys into
auth.User, changing it is a migration problem out of
proportion to the benefit. What I actually wanted was custom
permissions, and those do not require a custom user model. The two permissions on
AssetAssignment came out of that.
Two traces of the detour are still in the tree: an unused
AbstractUser import at the top of
models.py, and a stale comment next to
AUTH_USER_MODEL. I left them until I write this up
honestly; they should go.
Things I know are wrong and have not fixed yet. Listing them is cheaper than being asked.
-
The issue path is not transactional.
unassign_confirmruns insidetransaction.atomic().assign_confirmdoes not. It loops over the selected assets, closing an existing assignment and creating a new one per iteration, with a read-then-write and no row lock. Two consequences: concurrent requests for the same asset could both create an open assignment, and a failure halfway through a multi-asset issue leaves the earlier assets committed and the rest not. The fix is the partial unique index above plusatomic()around the loop, andselect_for_update()on the open-assignment lookup. -
No test suite.
core/tests.pyis still the five-line filestartappgenerated. For a system whose entire value is one invariant about custody, the first test to write is obvious, and its absence is the least defensible thing on this page. -
Reference-data hygiene. Beyond
model: 528 of 684 assets have no service tag,invoice_numberis a legacy column now superseded byvendorand still carrying its old contents, and the placeholder values in Fig. 9 are all still there. -
Leftovers from the user-model detour. An unused
AbstractUserimport and a stale comment besideAUTH_USER_MODEL. Trivial, but they are the kind of thing that makes a reader wonder what else was left half-done.
The order is deliberate: the concurrency gap and the missing tests are the same job, because the test that proves an asset cannot be held twice is also the test that proves the fix worked.