CodeOath
← All posts
Python65 min total · 18 parts

Django Fundamentals: The ORM, Migrations, and Shipping a Real App

Part 16 of 18 · ~2 min

Signals, and a Fix That Quietly Breaks Them

from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=Loan)
def free_up_tool_on_return(sender, instance, created, **kwargs):
    if not created and instance.status == "returned":
        instance.tool.is_available = True
        instance.tool.save()

The view that saves a Loanrequest_loan, or whatever admin action marks one returned — never has to import this function, call it, or know it's there; Django finds it purely because it's registered against post_save on the Loan model. created carries the distinction that makes one receiver able to serve two purposes: it's True only on that row's very first insert and False on every update after, so a single function can tell a brand-new borrow request apart from a status flip to "returned" without needing separate signals for each. The convenience has a cost attached, though. Follow this pattern too far and answering "why is this tool showing as available again" stops being a matter of reading request_loan top to bottom — the actual cause is off in a completely different file, wired up by a decorator nothing in the view even hints at.

Here's the sharper trap, verified directly against a real save: the moment ToolShed's moderators wanted to bulk-close a batch of stale loans instead of clicking "returned" on each one individually, the obvious-looking fix quietly broke this exact signal.

Loan.objects.filter(status="approved", due_date__lt=cutoff).update(status="returned")

QuerySet.update() writes directly to the database in one statement — it does not call .save() on each row, and confirmed directly: it does not fire pre_save or post_save at all, and it does not touch auto_now fields either, since both of those are things Django's model-level .save() does on the way through, and .update() bypasses that path entirely by design, for performance, on purpose. Every loan in that batch flips to "returned" in the database, correctly — and every tool those loans pointed at stays marked unavailable, because the signal that's supposed to free them back up never ran. The fix isn't to avoid .update() — it's genuinely the right tool for a bulk write — it's knowing that anything wired through a post_save signal needs its own explicit handling whenever a bulk operation is the one doing the saving.