Integrations Domain - Signals

Overview

Note: The Integrations domain does not have a signals.py file. This is by design.

Why No Signals in Integrations?

The Integrations domain is the infrastructure layer that handles bidirectional synchronization with Sanity CMS. It does not define its own Django signals because:

  1. Signals are domain-specific - Each business domain (Users, Products, Stores, Promotions, Transactions) defines its own signals to trigger outbound sync
  2. Integrations provides the tools - This domain provides the sync handlers, webhook receivers, and context managers that other domains use
  3. Separation of concerns - Business logic signals belong in business domains, not in the integration infrastructure

Where to Find Sync Signals

Sync signals are defined in each domain that needs to sync with Sanity:

Users Domain Signals

Location: api/nextango/apps/users/signals.py

Handlers:

  • sync_user_to_sanity - post_save signal for User model
  • delete_user_from_sanity - post_delete signal for User model
  • Employee, Customer, Manager, Influencer, VIP role sync handlers

Documentation: docs/overview/users/signals.md

Products Domain Signals

Location: api/nextango/apps/products/signals.py

Handlers:

  • sync_product_to_sanity - post_save signal for Product model
  • sync_product_variant_to_sanity - post_save signal for ProductVariant model
  • sync_category_to_sanity - post_save signal for Category model
  • Corresponding delete handlers

Documentation: docs/overview/products/signals.md (when created)

Stores Domain Signals

Location: api/nextango/apps/stores/signals.py

Handlers:

  • sync_store_to_sanity - post_save signal for Store model
  • delete_store_from_sanity - post_delete signal for Store model

Documentation: docs/overview/stores/signals.md (when created)

Promotions Domain Signals

Location: api/nextango/apps/promotions/signals.py

Handlers:

  • sync_promotional_campaign_to_sanity - post_save for PromotionalCampaign
  • sync_promo_code_to_sanity - post_save for PromoCode
  • sync_ab_test_to_sanity - post_save for ABTest
  • Corresponding delete handlers

Documentation: docs/overview/promotions/signals.md

Transactions Domain Signals

Location: api/nextango/apps/transactions/signals.py

Handlers:

  • sync_transaction_to_sanity - post_save for SaleTransaction
  • Transaction state change signals
  • Inventory update triggers

Documentation: docs/overview/transactions/signals.md

What Integrations Provides

Instead of signals, the Integrations domain provides the infrastructure components that domain signals use:

1. Sync Context Manager

Location: api/nextango/apps/integrations/sync/sync_context.py

Purpose: Thread-local context manager to prevent infinite sync loops

Usage in Domain Signals:

from nextango.apps.integrations.sync.sync_context import should_skip_sync_to_sanity, SyncContext

@receiver(post_save, sender=User)
def sync_user_to_sanity(sender, instance, created, **kwargs):
    # Check if we should skip sync (webhook processing)
    if should_skip_sync_to_sanity():
        return

    # Perform sync...

Documentation: docs/overview/integrations/sync_context.md

2. Sync Handlers

Location: api/nextango/apps/integrations/sync/

Components:

  • user_sync.py - UserSyncHandler
  • product_sync.py - ProductSyncHandler
  • store_sync.py - StoreSyncHandler
  • promotional_campaign_sync.py - PromotionalCampaignSyncHandler
  • transaction_sync.py - TransactionSyncHandler

Purpose: Handle the actual API calls to Sanity CMS

Usage in Domain Signals:

from nextango.apps.integrations.sync.user_sync import user_sync_handler

@receiver(post_save, sender=User)
def sync_user_to_sanity(sender, instance, created, **kwargs):
    # ... loop prevention checks ...

    # Delegate to sync handler
    user_sync_handler.sync_to_sanity(instance)

Documentation: docs/overview/integrations/services.md

3. Webhook Receiver

Location: api/nextango/apps/integrations/webhooks.py

Purpose: Receive inbound webhooks from Sanity and process document updates

How It Works:

  1. Validates webhook signature (HMAC-SHA256)
  2. Checks for ping-pong loops
  3. Routes to appropriate domain serializer
  4. Updates Django models without triggering outbound sync (using SyncContext)

Documentation: docs/overview/integrations/views.md

Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│                     Business Domains                             │
│  (Users, Products, Stores, Promotions, Transactions)            │
│                                                                   │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  Django Model .save()                                   │    │
│  └───────────────────┬────────────────────────────────────┘    │
│                      │                                           │
│                      ▼                                           │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  Domain-Specific Signal Handler                        │    │
│  │  (users/signals.py, products/signals.py, etc.)         │    │
│  │                                                         │    │
│  │  • Check should_skip_sync_to_sanity()                  │    │
│  │  • Check cache locks                                   │    │
│  │  • Delegate to sync handler                            │    │
│  └───────────────────┬────────────────────────────────────┘    │
└────────────────────┼───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│              Integrations Domain Infrastructure                  │
│                                                                   │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  Sync Handlers (user_sync, product_sync, etc.)         │    │
│  │  • Transform Django model → Sanity document            │    │
│  │  • Make API calls to Sanity                            │    │
│  │  • Handle errors and retries                           │    │
│  └───────────────────┬────────────────────────────────────┘    │
│                      │                                           │
│                      ▼                                           │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  Sanity CMS via sanity_client.py                       │    │
│  └────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘

Inbound Flow (Sanity → Django):
┌─────────────────────────────────────────────────────────────────┐
│  Sanity CMS Webhook                                              │
└───────────────────┬─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  Integrations: webhooks.py (SanityWebhookReceiver)              │
│  • Validate HMAC signature                                       │
│  • Check ping-pong prevention                                    │
│  • Route to domain serializer                                    │
└───────────────────┬─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  Business Domain Serializer                                      │
│  • Validate webhook payload                                      │
│  • Save with SyncContext (prevents outbound sync)                │
└─────────────────────────────────────────────────────────────────┘

Common Signal Pattern

All domain signal handlers follow this pattern:

from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from nextango.apps.integrations.sync.sync_context import should_skip_sync_to_sanity, SyncContext
from nextango.apps.integrations.sync.{domain}_sync import {domain}_sync_handler
from .models import MyModel
import logging
import os

logger = logging.getLogger(__name__)

@receiver(post_save, sender=MyModel, dispatch_uid='sync_mymodel_to_sanity')
def sync_mymodel_to_sanity(sender, instance, created, **kwargs):
    """
    Sync MyModel changes to Sanity CMS.
    Uses multi-layer loop prevention.
    """

    # Layer 1: LOAD_TESTING_MODE check
    if os.getenv('LOAD_TESTING_MODE') == 'true':
        logger.info(f"LOAD TESTING MODE: Skipping Sanity sync for MyModel {instance.pk}")
        return

    # Layer 2: Thread-local context check
    if should_skip_sync_to_sanity():
        sync_source = SyncContext.get_sync_source()
        logger.info(f"Skipping MyModel->Sanity sync for {instance.pk} (source: {sync_source})")
        return

    # Layer 3: Instance type verification
    if not isinstance(instance, MyModel):
        logger.debug(f"Skipping sync - instance is {type(instance).__name__}")
        return

    # Layer 4: Instance _sync_source attribute
    sync_source = getattr(instance, '_sync_source', None)
    if sync_source == 'sanity_webhook':
        logger.info(f"Skipping MyModel sync for {instance.pk} - from webhook")
        return

    # Layer 5: Cache webhook processing key
    webhook_key = f"webhook_processing:mymodel:{instance.pk}"
    if cache.get(webhook_key):
        logger.info(f"Skipping MyModel sync for {instance.pk} - webhook in progress")
        return

    # Layer 6: Cache django_sync lock
    sync_key = f"django_sync:mymodel:{instance.pk}"
    if cache.get(sync_key):
        logger.info(f"Skipping MyModel sync for {instance.pk} - sync in progress")
        return

    # Layer 7: Rapid save detection
    rapid_key = f"rapid_save:mymodel:{instance.pk}"
    if cache.get(rapid_key):
        logger.info(f"Skipping MyModel sync for {instance.pk} - rapid saves")
        return

    cache.set(rapid_key, True, 2)  # 2-second window
    cache.set(sync_key, True, 300)  # 5-minute lock

    try:
        # Delegate to Integrations sync handler
        mymodel_sync_handler.sync_to_sanity(instance)
        action = "created" if created else "updated"
        logger.info(f"MyModel {action} synced to Sanity: {instance.pk}")

    except Exception as e:
        logger.error(f"Failed to sync MyModel {instance.pk} to Sanity: {str(e)}", exc_info=True)

    finally:
        # Always clear the sync lock
        cache.delete(sync_key)


@receiver(post_delete, sender=MyModel)
def delete_mymodel_from_sanity(sender, instance, **kwargs):
    """
    Delete MyModel from Sanity when deleted in Django.
    """

    # Same loop prevention checks as above...

    if instance.sanity_id:
        try:
            mymodel_sync_handler.delete_from_sanity(instance.sanity_id)
            logger.info(f"MyModel deleted from Sanity: {instance.sanity_id}")
        except Exception as e:
            logger.error(f"Failed to delete MyModel from Sanity: {str(e)}", exc_info=True)

Summary

Integrations Domain Role:

  • Provides sync infrastructure (handlers, context managers, webhook receivers)
  • Handles Sanity API communication
  • Manages loop prevention mechanisms
  • Does NOT define business domain signals

Business Domain Role:

  • Defines signals for their own models
  • Uses Integrations infrastructure to perform sync
  • Implements domain-specific sync logic

This separation keeps integration infrastructure reusable across all domains while allowing each domain to control its own sync behavior.

Deletion Signal Handlers Wired to Sanity

When Django records are deleted, the corresponding Sanity CMS document is removed synchronously via SanityAPIClient.delete_document(). The following post_delete signal handlers are active:

transactions/signals.py

HandlerSenderNotes
delete_sale_transaction_from_sanitySaleTransactionUses transaction.on_commit() — Sanity delete runs after DB transaction commits

The SaleTransaction handler dispatches deletion via a helper _dispatch_transaction_deletion(instance, 'SaleTransaction') which calls SanityAPIClient().delete_document(instance.sanity_id).

procurement/signals.py

HandlerSenderNotes
delete_supplier_from_sanitySupplierSynchronous — no on_commit wrapper
delete_purchaseorder_from_sanityPurchaseOrderSynchronous — no on_commit wrapper

PurchaseOrderLineItem records are deleted via DB FK cascade and do not have their own Sanity delete handler — their content is embedded in the parent PurchaseOrder document.

Previously: orphaned Sanity records

Before these handlers were added, deleting a Django record left an orphaned document in Sanity CMS. The DELETE endpoints on the affected models now trigger Sanity document removal automatically.

Failure behavior

The Sanity delete call is synchronous. If Sanity is unreachable at the time of deletion, the Django delete still succeeds (the exception is caught and logged) but the Sanity document remains until manually reconciled. Operators can identify orphans by querying Sanity for documents whose _id does not exist in Django.

Loop prevention

All deletion handlers check should_skip_sync_to_sanity() and LOAD_TESTING_MODE before calling Sanity, consistent with the common signal pattern described in this file.


  • Sync Context: docs/overview/integrations/sync_context.md - Thread-local loop prevention
  • Sync Handlers: docs/overview/integrations/services.md - Domain-specific sync handlers
  • Webhook Receiver: docs/overview/integrations/views.md - Inbound webhook processing
  • User Signals: docs/overview/users/signals.md - Example domain signal implementation
  • Promotions Signals: docs/overview/promotions/signals.md - Example domain signal implementation

Was this page helpful?