MA Mojtaba Almadan Plain version → العربية

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.

Live data
684 assets · 933 employee records (910 active) · 1,423 assignment records · 428 currently in someone's hands
Stack
Django 6.0 · PostgreSQL · server-rendered templates · no SPA, no build step
Reference data
22 projects · 11 categories · 10 brands
Access
5 accounts across 4 permission groups, gated per view
Role
Sole engineer: schema, application, deployment, backups, training

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.

Problem

Before

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.

Data model

6 tables
14 migrations

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.

BRAND id PK name UQ CATEGORY id PK name UQ ASSET id PK name brand_id FK category_id FK model ← free text serial_no condition price, dates, … ASSETASSIGNMENT id PK asset_id FK employee_id FK project_id FK assigned_at returned_at NULL employee_signature authority_signature notes, emails EMPLOYEE id PK employee_no UQ name project_id FK position is_active contact, govt id PROJECT id PK name code UQ 1 N PROTECT 1 N PROTECT 1 N PROTECT 1 N PROTECT 1 N SET_NULL 1 N SET_NULL
Fig. 1. The real schema, fields abbreviated. The 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.

Constraint

The one that
didn't work

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:

0001
unique_together = {("asset", "returned_at")}
0007
unique_together = set()  ← removed
Fig. 2. Six migrations apart.

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:

constraint
UniqueConstraint(
  fields=["asset"],
  condition=Q(returned_at__isnull=True),
  name="one_open_assignment_per_asset",
)
Fig. 3. Uniqueness over the open rows only. Closed assignments are unconstrained, so an asset can be issued and returned any number of times, but only ever held once.

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.

Write path

Issuing
an asset

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.

1 · Auth
@login_required then @permission_required("core.add_assetassignment", raise_exception=True)
2 · Signatures
Both employee and authority signatures required; data-URL from the signature pad decoded to a file; 19 MB ceiling; expired uploads rejected
3 · Validate
Errors collected into a list and re-rendered on the confirm page rather than thrown
4 · Per asset
Find any assignment with returned_at IS NULL → close it with timezone.now()
5 · Per asset
Create the new assignment, inheriting the employee's project
6 · Per asset
Attach each signature image, saving twice so upload_to can resolve the per-role path
7 · Redirect
Back to the asset list
Fig. 4. Step 4 is a transfer, not a rejection: issuing an asset someone else holds closes their assignment automatically rather than erroring. That was a deliberate choice. In practice equipment moves desk to desk without coming back to IT first, and a system that refuses to record what already happened just gets worked around.

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:

path
signatures/<assignment_id>/employee.png
signatures/<assignment_id>/authority.png
cost
Two extra save(update_fields=[…]) calls per assignment, because the id must exist before the path can be built
Fig. 5. It works and it is contained, but it is state smuggled through an instance attribute, and I would rather have two explicit callables. Naming the files by role instead of by upload means a signature can be found from an assignment id alone, which matters when someone asks who signed for a machine two years ago.
Interface

Sketch, not
a screenshot

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.

KIC IT Asset Management System Assign assets  ·  signed in as kic-it
Home Assets Employees Assets history Employees history Brands Categories Projects
Assets + Add asset
Search… Search Export CSV Export Excel
« Prev 1 2 3 … 14 Next »
# ⇅ Asset id ⇅ Name ⇅ S/N ⇅ Brand ⇅ Model ⇅ Status Emp no
10412DesktopXXXXXXXBrand AModel 1000Available–
20413LaptopXXXXXXXBrand BModel 2000Assigned0091
30414Monitor 24"XXXXXXXBrand AModel 24HAssigned0143
40415PrinterXXXXXXXBrand CModel 7740Available–
Fig. 6. An interface sketch, redrawn for this page in this site's own styling. It is not a screenshot, and every brand, model, serial and number in it is invented. The real screens carry employee names, government ID numbers, mobile numbers and signatures, none of which belongs on a public page. What is faithful is the structure: a sidebar where every entry is gated on a permission, one search box over the whole table, sortable columns, CSV and Excel export, 14 pages of assets at this page size, and a status column resolved through the open assignment rather than stored on the asset.
Operations

Backup
& restore

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.

Database
dumpdata --natural-foreign --natural-primary --exclude=contenttypes --exclude=auth.Permission
Signatures
The media signatures tree, zipped. The images are the evidence, so they travel with the rows that reference them
Source
config/, core/, static/ zipped
Naming
Timestamped file plus a _latest pointer, so restore has a sane default
Fig. 7. The two --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.

What I'd
change

Two things

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:

Assets
684
Distinct model strings
255
… after lowercasing and removing punctuation
183, so 72 were pure formatting variants
Dell OptiPlex family
326 assets, spelled 71 different ways
Lenovo Think… family
84 assets, spelled 34 different ways
Fig. 8. Measured over the live data. The OptiPlex variants include 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.

Assets
10 have the serial number copied into model; 3 have the asset name there; 25 hold a purely numeric placeholder
Employees
2 have the person's name in the government-ID column and 2 in the mobile column; 6 have the same value in both
Completeness
561 of 933 employees have no government ID recorded at all; 27 of the rest are not in the 10-digit national-ID format
Fig. 9. None of these are typos a person would make twice. They are what a spreadsheet import does when the source columns don't line up and nothing downstream objects. A 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.

Known gaps

Open work

Things I know are wrong and have not fixed yet. Listing them is cheaper than being asked.

  • The issue path is not transactional. unassign_confirm runs inside transaction.atomic(). assign_confirm does 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 plus atomic() around the loop, and select_for_update() on the open-assignment lookup.
  • No test suite. core/tests.py is still the five-line file startapp generated. 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_number is a legacy column now superseded by vendor and still carrying its old contents, and the placeholder values in Fig. 9 are all still there.
  • Leftovers from the user-model detour. An unused AbstractUser import and a stale comment beside AUTH_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.