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:
- Signals are domain-specific - Each business domain (Users, Products, Stores, Promotions, Transactions) defines its own signals to trigger outbound sync
- Integrations provides the tools - This domain provides the sync handlers, webhook receivers, and context managers that other domains use
- 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 modeldelete_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 modelsync_product_variant_to_sanity- post_save signal for ProductVariant modelsync_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 modeldelete_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 PromotionalCampaignsync_promo_code_to_sanity- post_save for PromoCodesync_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- UserSyncHandlerproduct_sync.py- ProductSyncHandlerstore_sync.py- StoreSyncHandlerpromotional_campaign_sync.py- PromotionalCampaignSyncHandlertransaction_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:
- Validates webhook signature (HMAC-SHA256)
- Checks for ping-pong loops
- Routes to appropriate domain serializer
- 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
| Handler | Sender | Notes |
|---|---|---|
delete_sale_transaction_from_sanity | SaleTransaction | Uses 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
| Handler | Sender | Notes |
|---|---|---|
delete_supplier_from_sanity | Supplier | Synchronous — no on_commit wrapper |
delete_purchaseorder_from_sanity | PurchaseOrder | Synchronous — 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.
Related Documentation
- 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