Products - Signals Documentation
Location: api/nextango/apps/products/signals.py
Last Updated: 2026-07-06
Overview
The Products signals module wires Django signal receivers for the 3-SoT catalog contract, inventory initialization, Elasticsearch reindexing, Sanity sync boundaries, and reference-data cache invalidation. The receivers fall into five groups:
- Derived-SoT cascade - ProductVariant model rows are canonical; saves propagate to the
Product.variantsJSONField and the Elasticsearch index - Initial inventory setup - one-time
InventoryLevelcreation when a variant is created - Elasticsearch reindex - inventory changes queue a product reindex, coalesced per transaction
- Sanity sync boundaries - outbound sync receivers exist but are disabled by design (sync direction is Sanity to Django), plus a one-shot inventory bootstrap
- Cache invalidation - taxonomy changes clear the HTTP response cache
Receivers that talk to Sanity carry layered loop-prevention guards so a webhook-originated save never feeds back into Sanity.
Reindex Coalescing
Several receivers request an Elasticsearch reindex of the same product within one transaction: a variant edit, every inventory-level save on a multi-line sale, every inventory-level delete on a variant hard-delete, every soft-delete on a sync. The module collapses these to a single Celery reindex dispatch per product per transaction, flushed when the transaction commits. Callers observe one reindex task per touched product, not one per event. Outside a transaction the dispatch happens immediately.
Signal Receivers
cascade_variant_change_to_derived_sots
Signal: post_save on ProductVariant
The 3-SoT contract for the catalog: ProductVariant model rows are the canonical source of truth, while the Product.variants JSONField and the ProductDocument Elasticsearch index are derived. On any ProductVariant save that did not originate from Sanity, this receiver propagates the variant snapshot to both derived sources.
What it does:
- Writes the variant snapshot into the parent
Product.variantsJSONField viaProductService.sync_variant_to_jsonfield - Queues a coalesced Elasticsearch reindex of the parent product
Both effects defer to transaction.on_commit; the reindex itself runs in Celery. The receiver skips saves that originated from a Sanity webhook (the same guard set as the outbound sync receivers) and skips variants already marked sync_status='deleted_from_sanity', which belong to cascade_variant_deletion_to_inventory.
set_stock_initial_for_variant
Signal: post_save on ProductVariant (created only)
When a ProductVariant is created directly (not via Product sync), this receiver creates InventoryLevel records seeded with the variant's initial stock value. This is a one-time operation: stock_initial is read only at creation, and later changes to it are ignored.
Target stores depend on the variant. A store-specific variant (store_specific_product=True with store_location refs) gets one record per referenced active store. Any other variant gets one record per active store.
Each created InventoryLevel is seeded with:
| Field | Value |
|---|---|
stock_on_hand | stock_initial |
stock_reserved | 0 |
stock_lifetime_received | stock_initial |
stock_initial | stock_initial (immutable snapshot) |
cost_per_unit | sale_price when on sale, otherwise price |
The receiver runs synchronously inside the save's transaction. It skips variants created through webhook or sync paths (those are handled by the sync pipeline), variants with digital_inventory=True (unlimited stock, no tracking), and variants without a product relationship. If an InventoryLevel already exists for the (product, SKU, store) key, the receiver does not touch its quantity. Adjust live stock through the InventoryLevel API, not through the variant.
cascade_variant_deletion_to_inventory
Signal: post_save on ProductVariant
When a variant transitions to sync_status='deleted_from_sanity' (soft-deleted by a Sanity sync), this receiver deactivates its inventory: it sets is_active=False on all of the variant's InventoryLevel rows and queues a coalesced Elasticsearch reindex of the parent product.
The is_active update runs synchronously; the reindex defers to commit and runs in Celery. The receiver is idempotent: if every inventory level is already inactive, it returns without re-queueing a reindex, so repeated saves of an already-deleted variant produce no extra work.
update_elasticsearch_on_inventory_change
Signal: post_save on InventoryLevel
The ProductDocument in Elasticsearch carries inventory_by_store data captured at index time. When inventory changes (a sale, return, or adjustment), this receiver queues a coalesced reindex of the related product so search results reflect current stock. The dispatch defers to commit; the reindex runs in Celery. Skipped when the inventory level has no associated product.
delete_inventory_level_from_elasticsearch
Signal: post_delete on InventoryLevel
When a ProductVariant with no transaction history is hard-deleted, Django cascade-deletes its InventoryLevel rows, and post_save never fires on a delete. This receiver queues the coalesced reindex on post_delete instead, removing stale inventory data from the product's search document. Deferred to commit; runs in Celery.
sync_inventory_level_to_sanity
Signal: post_save on InventoryLevel
A one-shot bootstrap. Sanity is the source of truth for editor-managed cost fields, so when Django creates an InventoryLevel, the row roundtrips to Sanity once. A policy check (should_bootstrap_to_sanity) gates the dispatch to creation only; operational saves stay Django-local, consistent with _outbound_sync_enabled = False on the model. The Celery sync task dispatches after commit.
sync_product_to_sanity
Signal: post_save on Product
Exists as the outbound sync boundary for Product, but outbound sync to Sanity is disabled by design: sync runs in one direction, Sanity to Django via webhooks. After the loop-prevention guards pass, the receiver defers to commit and logs that outbound sync is disabled. No task is dispatched.
The guards reject saves that originated from Sanity: load-testing mode, the sync-context guard, the instance flags _syncing_from_sanity, _webhook_processing, _skip_sanity_sync, _from_webhook, a _sync_source of 'sanity_webhook', a recent-webhook cache check, and a missing sanity_id.
sync_product_variant_to_sanity
Signal: post_save on ProductVariant
The variant counterpart of sync_product_to_sanity, also disabled. Variants are inline objects inside Sanity product documents: they carry a _key within the product's variants array, not their own _id, so any variant change in Sanity flows through the parent Product document. The receiver applies the same guard set, requires the parent product to have a sanity_id, then defers to commit and logs only.
delete_product_from_sanity_signal
Signal: post_delete on Product
The outbound deletion boundary, also disabled. After checking load-testing mode, the presence of sanity_id, and the _syncing_from_sanity flag, the receiver defers to commit and logs that outbound deletion sync is disabled.
delete_product_variant_from_sanity_signal
Signal: post_delete on ProductVariant
Disabled outbound boundary for variant removal. Removing a variant in Sanity would mean updating the parent product document (variants are inline objects), but the push task is deprecated. The receiver applies the sync guards, requires a parent product with a sanity_id, then defers to commit and logs only.
Cache invalidation receivers
Signals: post_save and post_delete on Brand, Category, ProductType, Collection
Four receivers (invalidate_brand_cache, invalidate_category_cache, invalidate_producttype_cache, invalidate_collection_cache) clear the HTTP response cache for their model's list and detail endpoints whenever a record is created, updated, or deleted. The taxonomy viewsets cache responses, and these receivers keep that cache fresh. Each runs synchronously.
Loop Prevention
Without guards, a save loop forms: Django saves a model, a signal pushes to Sanity, the Sanity webhook writes back to Django, and the cycle repeats. The Sanity-facing receivers defend in layers:
- Environment: load-testing mode bypasses all sync receivers
- Sync context: a request-scoped guard (
should_skip_sync_to_sanity) marks webhook processing - Instance flags:
_syncing_from_sanity,_webhook_processing,_skip_sanity_sync,_from_webhook, and_sync_sourcemark the origin of a save - Cache: a recent-webhook check catches processing that spans requests
The webhook pipeline activates the sync-context guard while it processes a payload, which is the layer that catches webhook-originated saves in practice. The instance flags are per-instance escape hatches the receivers honor; when creating records manually outside the sync pipeline, set _skip_sanity_sync = True before save().
Known Gaps
Bulk operations bypass signals. bulk_create, bulk_update, and queryset update() do not fire post_save. Variants created via bulk_create get no InventoryLevel seeding and no JSONField cascade; inventory updated via queryset update() triggers no Elasticsearch reindex; taxonomy rows changed in bulk leave the response cache stale. Code paths that mutate these models in bulk must perform the side effects explicitly or trigger a reconcile afterward (see the reconcile tasks in Tasks).
Initial inventory is creation-only. Changing stock_initial on an existing variant has no effect on live stock. The field is an immutable snapshot after creation.
Best Practices
Creating products manually
from nextango.apps.products.models import Product
product = Product(
name="New Product",
slug="new-product",
)
product._skip_sanity_sync = True
product.save()
Creating variants with inventory
from nextango.apps.products.models import ProductVariant
# The post_save receiver creates InventoryLevel records automatically
variant = ProductVariant.objects.create(
product=product,
sku="NEW-SKU",
name="New Variant",
price=29.99,
stock_initial=100, # Used only for initial InventoryLevel creation
)
# One InventoryLevel per active store, seeded stock_on_hand=100
Updating inventory
from nextango.apps.products.models import InventoryLevel
# CORRECT: update InventoryLevel directly
inv_level = InventoryLevel.objects.get(variant_sku="NEW-SKU", store=store)
inv_level.stock_on_hand = 150
inv_level.save()
# INCORRECT: stock_initial is read once at creation
variant = ProductVariant.objects.get(sku="NEW-SKU")
variant.stock_initial = 150 # This does NOT update live inventory
variant.save()
Testing with signals disconnected
from django.db.models.signals import post_save
from nextango.apps.products.models import ProductVariant
from nextango.apps.products.signals import set_stock_initial_for_variant
post_save.disconnect(set_stock_initial_for_variant, sender=ProductVariant)
try:
variant = ProductVariant.objects.create(...)
finally:
post_save.connect(set_stock_initial_for_variant, sender=ProductVariant)