The loans app's whole reason to exist is one model — the record of a tool moving from one neighbor's garage to another's for a while:
class Loan(models.Model):
STATUS_CHOICES = [
("requested", "Requested"),
("approved", "Approved"),
("returned", "Returned"),
]
tool = models.ForeignKey("catalog.Tool", on_delete=models.CASCADE, related_name="loans")
borrower = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="loans_borrowed")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="requested")
due_date = models.DateField()
requested_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-requested_at"] # newest loan requests first, everywhere this model is queried
indexes = [models.Index(fields=["status"])]
def __str__(self):
return f"Loan #{self.pk} — {self.tool.name} to {self.borrower.username}"
@property
def is_overdue(self):
from django.utils import timezone
return self.status == "approved" and self.due_date < timezone.now().date()
That class, run through a migration, gives ToolShed a real database table and a Python API for it without a hand-written CREATE TABLE anywhere. A handful of details here are worth getting exactly right, because each one has a specific, waiting-to-happen failure mode:
auto_now_add writes a timestamp exactly once, the moment a row is first created, and then never touches that column again for the rest of the row's life. auto_now is the one that keeps writing — it stamps the current instant into the column on every .save(), regardless of which fields the caller actually meant to change. Put auto_now where auto_now_add belongs on requested_at, and a loan's "started" timestamp keeps sliding forward every time its status changes, until nothing about when it was actually requested is left in the data.choices only ever gets enforced by code that chooses to run Django's validation — the database itself has no idea it exists. A row inserted through raw SQL, or saved through a code path that never calls full_clean(), sails straight past it; underneath, the column is an ordinary VARCHAR that will happily hold status="on_fire" or any other string you hand it. Treating choices as if it were a guarantee, rather than a UI convenience, is how a value nothing on the form ever offered ends up sitting in a live row. Closing that gap for real means going one layer down, to Meta.constraints:class Loan(models.Model):
# ...same fields as above...
class Meta:
ordering = ["-requested_at"]
indexes = [models.Index(fields=["status"])]
constraints = [
models.CheckConstraint(
check=models.Q(status__in=["requested", "approved", "returned"]),
name="loan_status_valid",
),
]
This compiles to an actual CHECK constraint in the database itself — verified directly against SQLite for this reference: a plain INSERT carrying a status outside that list gets rejected by the database with an integrity error, before Django's own validation ever gets a chance to run. choices and a CheckConstraint aren't competing options; they solve two different layers of the same problem, and a model that only has the first is one raw script away from a row nothing can make sense of.
Meta.ordering sets the default order — any .order_by() you write later overrides it, per query, without complaint. Lean on the default anywhere you actually need a stable, repeatable order — paginating a long loan history is the classic case — and you'll eventually see the same loan twice across two pages, because "newest first" isn't guaranteed stable when two loans share the same requested_at down to the microsecond.__str__ decides how an instance identifies itself everywhere Django has to print one — inside the admin's own list pages, in a shell session, in any stray print(loan) left in the code. Without it, every single Loan prints as the generic, interchangeable Loan object (1), and picking the one broken row out of a debug session with forty of them on screen turns into guesswork by position instead of by anything actually meaningful.