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.variants JSONField and the Elasticsearch index
  • Initial inventory setup - one-time InventoryLevel creation 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:

  1. Writes the variant snapshot into the parent Product.variants JSONField via ProductService.sync_variant_to_jsonfield
  2. 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:

FieldValue
stock_on_handstock_initial
stock_reserved0
stock_lifetime_receivedstock_initial
stock_initialstock_initial (immutable snapshot)
cost_per_unitsale_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_source mark 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)

  • Models - Product, ProductVariant, and InventoryLevel models
  • Services - ProductService.sync_variant_to_jsonfield and sync services
  • Tasks - Celery tasks, including the inventory reconcile chords
  • Search - the ProductDocument index the reindex signals maintain
  • Examples - usage examples

Was this page helpful?