Procurement - Django Signals

Location: api/nextango/apps/procurement/signals.py Last Updated: 2026-07-09

Overview

The Procurement signals module implements Django signal receivers for bidirectional synchronization of Supplier and PurchaseOrder models with Sanity CMS. Signals also handle automated inventory and cost updates when purchase orders are received, and keep PurchaseOrder.total_cost in sync with its line items. Multiple layers of loop prevention ensure Django-initiated saves do not re-trigger syncs originating from Sanity webhooks.


Signal Summary

SignalModelTriggerPurpose
sync_supplier_to_sanitySupplierpost_saveSync supplier changes to Sanity CMS
delete_supplier_from_sanitySupplierpost_deleteDelete supplier document from Sanity
sync_purchaseorder_to_sanityPurchaseOrderpost_saveSync PO changes to Sanity CMS
delete_purchaseorder_from_sanityPurchaseOrderpost_deleteDelete PO document from Sanity
snapshot_po_statusPurchaseOrderpre_saveCapture pre-save status for transition detection
update_inventory_on_receivingPurchaseOrderpost_saveIncrement inventory and update average cost on PO receipt
update_po_total_costPurchaseOrderLineItempost_save, post_deleteKeep PurchaseOrder.total_cost in sync with line items

Signal Reference

sync_supplier_to_sanity

Trigger: post_save on Supplier Conditions: Fires on every save unless a loop prevention guard is active.

Purpose: Orchestrates outbound sync of a Supplier to Sanity CMS using SyncOrchestrator. Skips sync when the save originated from a Sanity webhook (_sync_source == 'sanity_webhook'), when a Redis cache key indicates a sync is already in progress, or when LOAD_TESTING_MODE=true is set.

Side Effects:

  • Sets a rapid_save_key cache entry (2 s TTL) to coalesce rapid successive saves
  • Sets a django_sync_key cache entry (300 s TTL) while sync is in progress; deletes it in a finally block
  • Calls SyncOrchestrator.orchestrate_sync_to_sanity() via the procurement_sync_handler
  • Logs success or failure at INFO/ERROR level

delete_supplier_from_sanity

Trigger: post_delete on Supplier Conditions: Only calls Sanity if instance.sanity_id is set.

Purpose: Deletes the corresponding Sanity document when a Supplier is deleted in Django.

Side Effects:

  • Calls SanityAPIClient().delete_document(instance.sanity_id)
  • Skipped entirely when LOAD_TESTING_MODE=true or when should_skip_sync_to_sanity() returns True

sync_purchaseorder_to_sanity

Trigger: post_save on PurchaseOrder Conditions: Same loop prevention guards as sync_supplier_to_sanity.

Purpose: Orchestrates outbound sync of a PurchaseOrder to Sanity CMS.

Side Effects:

  • Sets rapid_save_key (2 s) and django_sync_key (300 s) cache entries scoped to purchaseorder:{pk}
  • Calls SyncOrchestrator.orchestrate_sync_to_sanity() via procurement_sync_handler

delete_purchaseorder_from_sanity

Trigger: post_delete on PurchaseOrder Conditions: Only calls Sanity if instance.sanity_id is set.

Purpose: Deletes the corresponding Sanity PO document when a PurchaseOrder is deleted in Django.

Side Effects:

  • Calls SanityAPIClient().delete_document(instance.sanity_id)

snapshot_po_status

Trigger: pre_save on PurchaseOrder Conditions: Only queries the database when instance.pk is set (i.e., not a new record).

Purpose: Reads the current status from the database before the save and stores it on instance._pre_save_status. This allows update_inventory_on_receiving (which fires in post_save) to detect the → Received status transition without a second database hit.

Side Effects:

  • Sets instance._pre_save_status to the current DB value, or None for new records

update_inventory_on_receiving

Trigger: post_save on PurchaseOrder Conditions: Only fires when status transitions into RECEIVED (i.e., _pre_save_status != 'Received' and new_status == 'Received'). Skipped for new records (created=True).

Purpose: When a PO is marked as received, increments stock_on_hand and stock_lifetime_received on every linked InventoryLevel, recalculates the weighted average cost, creates InventoryMovement audit records, records the received_date on the PO, and logs lead time analytics.

Side Effects:

  • For each PurchaseOrderLineItem with a linked InventoryLevel:
    • Calls InventoryLevel.objects.filter(pk=...).update(stock_on_hand=F(...)+qty, stock_lifetime_received=F(...)+qty, average_cost=..., last_purchase_cost=..., last_cost_update=now())
    • Creates an InventoryMovement record with movement_type='receiving'
  • received_date auto-set: PurchaseOrder.objects.filter(pk=...).update(received_date=date.today()) is called via QuerySet.update() to avoid re-triggering post_save. The field is set to today's date: no manual entry needed. If received_date was set manually on the instance before status transition, the signal will override it.
  • Lead time analytics logged:
    • Actual lead time (days from created_at.date() to today) is logged at INFO level.
    • If expected_date is set: logs whether the PO arrived early, on time, or late (WARNING if late).
    • Supplier lead_time_days is updated via running average, round((old_lead_time + actual_days) / 2), using QuerySet.update() to avoid triggering the supplier's post_save signal.
  • Errors for individual line items are caught and logged without aborting the remaining items

update_po_total_cost

Trigger: post_save and post_delete on PurchaseOrderLineItem

Purpose: Recalculates PurchaseOrder.total_cost as the sum of unit_cost × quantity across all line items and updates it via QuerySet.update().

Side Effects:

  • Calls PurchaseOrder.objects.filter(pk=...).update(total_cost=...)
  • Falls back to Decimal('0.00') when no line items remain

Was this page helpful?