Python65 min total · 18 parts
Django Fundamentals: The ORM, Migrations, and Shipping a Real App
Part 4 of 18 · ~3 min
Building the Loan Model
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_addwrites 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_nowis 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. Putauto_nowwhereauto_now_addbelongs onrequested_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.choicesonly 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 callsfull_clean(), sails straight past it; underneath, the column is an ordinaryVARCHARthat will happily holdstatus="on_fire"or any other string you hand it. Treatingchoicesas 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, toMeta.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.orderingsets 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 samerequested_atdown 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 strayprint(loan)left in the code. Without it, every singleLoanprints as the generic, interchangeableLoan 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.