Zones Signals - Infrastructure Automation
Location: api/nextango/apps/zones/signals.py
Last Updated: 2025-12-21
Overview
The zones signals module automates infrastructure creation and cleanup for zone-driven inventory management. Unlike web_auth (which explicitly avoids signals), zones uses signals appropriately for infrastructure automation - ensuring InventoryLevel records exist when par levels are created and cleaning up orphaned records when par levels are deleted.
Key Features:
- Automatic inventory infrastructure: Auto-create InventoryLevel when ParLevelItem is created
- Orphaned record cleanup: Remove InventoryLevel when last ParLevelItem is deleted
- Zone configuration automation: Auto-create zone type configurations (compliance, merchandising, inventory)
- Safe deletion logic: Only delete InventoryLevel if no other par levels exist
- Error resilience: All signal handlers wrapped in try/except with logging
Signal Handlers (3 total):
- create_inventory_level_for_par_level - Auto-create inventory infrastructure
- cleanup_orphaned_inventory_level - Remove orphaned inventory records
- create_zone_configuration - Auto-create zone type configurations
When Signals Are Appropriate:
Zones signals are good examples of when signals SHOULD be used:
- Infrastructure automation (creating related records)
- Cleanup operations (removing orphaned data)
- Side effects that don't affect business logic flow
- Operations that should happen regardless of how the model is saved
Contrast with web_auth:
- web_auth avoids signals for authentication logic (needs explicit control)
- zones uses signals for infrastructure automation (implicit is fine)
Signal Handler 1: create_inventory_level_for_par_level
Automatically create InventoryLevel record when a ParLevelItem is created.
Signal Configuration
@receiver(post_save, sender=ParLevelItem)
def create_inventory_level_for_par_level(sender, instance, created, **kwargs):
Trigger: post_save signal on ParLevelItem model
Condition: Only fires when created=True (new records only, not updates)
Sender: ParLevelItem model
Purpose
Ensures inventory tracking infrastructure exists immediately when par levels are assigned. This automates the "zones → par levels → inventory levels" architecture without requiring manual intervention.
Implementation
@receiver(post_save, sender=ParLevelItem)
def create_inventory_level_for_par_level(sender, instance, created, **kwargs):
"""
Automatically create InventoryLevel record when a ParLevelItem is created.
This ensures inventory tracking infrastructure exists for zone assignments.
"""
if created:
try:
inventory_level = instance.ensure_inventory_level_exists()
if inventory_level:
logger.info(f"Auto-created InventoryLevel for par level {instance.pk}")
except Exception as e:
logger.error(f"Failed to auto-create InventoryLevel for par level {instance.pk}: {e}")
Workflow
1. Sanity webhook creates new ParLevelItem via ZoneSerializer
↓
2. Django saves ParLevelItem to database
↓
3. post_save signal fires with created=True
↓
4. Signal handler calls instance.ensure_inventory_level_exists()
↓
5. ParLevelItem.ensure_inventory_level_exists() creates InventoryLevel
↓
6. InventoryLevel created with:
- product = par_level.product
- variant_sku = par_level.variant_sku
- store = par_level.zone_inventory.zone.store_location
- quantity_on_hand = 0 (initial)
ensure_inventory_level_exists() Method
From ParLevelItem model:
def ensure_inventory_level_exists(self):
"""Ensure InventoryLevel exists for this par level item."""
InventoryLevel = apps.get_model('products', 'InventoryLevel')
inventory_level, created = InventoryLevel.objects.get_or_create(
product=self.product,
variant_sku=self.variant_sku,
store=self.zone_inventory.zone.store_location,
defaults={
'quantity_on_hand': 0,
'quantity_allocated': 0,
'quantity_incoming': 0
}
)
if created:
inventory_level._created = True
return inventory_level
Key Points:
- Uses
get_or_create()- idempotent operation - Sets
_createdflag for tracking - Returns existing record if already exists
- Creates with zero quantities if new
Example: Par Level Creation
Scenario: Sanity webhook creates par level for "Dark Roast Coffee - 12oz" in "Coffee Bar" zone
ParLevelItem Created:
{
"id": "uuid-par-123",
"product": Product("Dark Roast Coffee"),
"variant_sku": "DARK-12OZ",
"quantity": 10, # Par level quantity
"zone_inventory": ZoneInventory(zone="Coffee Bar", store="Downtown")
}
Signal Fires → InventoryLevel Auto-Created:
{
"id": "uuid-inv-456",
"product": Product("Dark Roast Coffee"),
"variant_sku": "DARK-12OZ",
"store": Store("Downtown"),
"quantity_on_hand": 0,
"quantity_allocated": 0,
"quantity_incoming": 0
}
Result:
- Par level defines "should have 10 units"
- InventoryLevel tracks "currently have 0 units"
- Infrastructure ready for inventory counting
Error Handling
Try/Except Wrapper:
try:
inventory_level = instance.ensure_inventory_level_exists()
if inventory_level:
logger.info(f"Auto-created InventoryLevel for par level {instance.pk}")
except Exception as e:
logger.error(f"Failed to auto-create InventoryLevel for par level {instance.pk}: {e}")
Why This Matters:
- Signal failures should not crash the application
- ParLevelItem creation succeeds even if InventoryLevel creation fails
- Errors are logged for debugging
- Manual sync can fix missing InventoryLevels later
Common Errors:
- Invalid variant_sku (no matching ProductVariant)
- Missing Product reference
- Database connection issues
- Store reference missing
Signal Handler 2: cleanup_orphaned_inventory_level
Clean up InventoryLevel record when ParLevelItem is deleted if no other par levels exist for the same product/variant/store combination.
Signal Configuration
@receiver(post_delete, sender=ParLevelItem)
def cleanup_orphaned_inventory_level(sender, instance, **kwargs):
Trigger: post_delete signal on ParLevelItem model
Condition: Always fires when ParLevelItem is deleted
Sender: ParLevelItem model
Purpose
Prevents orphaned InventoryLevel records from accumulating when par levels are removed. Only deletes InventoryLevel if it's the LAST par level for that product/variant/store combination.
Implementation
@receiver(post_delete, sender=ParLevelItem)
def cleanup_orphaned_inventory_level(sender, instance, **kwargs):
"""
Clean up InventoryLevel record when ParLevelItem is deleted if no other
par levels exist for the same product/variant/store combination.
"""
try:
InventoryLevel = apps.get_model('products', 'InventoryLevel')
store = instance.zone_inventory.zone.store_location
# Check if any other par levels exist for this product/variant/store
other_par_levels = ParLevelItem.objects.filter(
product=instance.product,
variant_sku=instance.variant_sku,
zone_inventory__zone__store_location=store
).exists()
if not other_par_levels:
# No other par levels exist, safe to remove inventory level
inventory_level = InventoryLevel.objects.filter(
product=instance.product,
variant_sku=instance.variant_sku,
store=store
).first()
if inventory_level:
inventory_level.delete()
logger.info(f"Cleaned up orphaned InventoryLevel for {instance.variant_sku} at {store.name}")
except Exception as e:
logger.error(f"Failed to cleanup InventoryLevel for deleted par level: {e}")
Safety Logic
Critical Safety Check:
other_par_levels = ParLevelItem.objects.filter(
product=instance.product,
variant_sku=instance.variant_sku,
zone_inventory__zone__store_location=store
).exists()
if not other_par_levels:
# ONLY delete if no other par levels exist
inventory_level.delete()
Why This Matters:
- A product can have par levels in MULTIPLE zones at the same store
- Example: "Dark Roast Coffee" might be in both "Coffee Bar" and "Back Stock" zones
- Only delete InventoryLevel when LAST par level is removed
- Prevents accidental deletion of inventory tracking
Example: Safe Deletion
Initial State:
Store: Downtown
Product: Dark Roast Coffee - 12oz (DARK-12OZ)
Par Levels:
- Coffee Bar zone: 10 units
- Back Stock zone: 20 units
InventoryLevel:
- Product: Dark Roast Coffee
- Variant: DARK-12OZ
- Store: Downtown
- Quantity: 15 units
Scenario 1: Delete Coffee Bar Par Level
# Delete par level from Coffee Bar zone
par_level_coffee_bar.delete()
# Signal fires
# Checks: Are there other par levels for DARK-12OZ at Downtown?
# Result: Yes, Back Stock zone still has par level
# Action: Do NOT delete InventoryLevel
# Outcome: InventoryLevel preserved (still tracking 15 units)
Scenario 2: Delete Back Stock Par Level (Last One)
# Delete par level from Back Stock zone
par_level_back_stock.delete()
# Signal fires
# Checks: Are there other par levels for DARK-12OZ at Downtown?
# Result: No, this was the last par level
# Action: DELETE InventoryLevel
# Outcome: InventoryLevel removed (no longer needed)
Error Handling
Try/Except Wrapper:
try:
# Cleanup logic
except Exception as e:
logger.error(f"Failed to cleanup InventoryLevel for deleted par level: {e}")
Why Silent Failure is OK:
- Orphaned InventoryLevels are not critical errors
- Can be cleaned up manually via ZoneAssignmentService.cleanup_orphaned_inventory_levels()
- ParLevelItem deletion succeeds regardless
- Prevents deletion failures from blocking zone management
Signal Handler 3: create_zone_configuration
Automatically create appropriate zone configuration (compliance, merchandising, or inventory) based on the zone type when a Zone is created.
Signal Configuration
@receiver(post_save, sender=Zone)
def create_zone_configuration(sender, instance, created, **kwargs):
Trigger: post_save signal on Zone model
Condition: Only fires when created=True (new zones only)
Sender: Zone model
Purpose
Ensures every zone has its appropriate configuration record immediately upon creation. This automates the relationship between Zone and its zone type modules.
Implementation
@receiver(post_save, sender=Zone)
def create_zone_configuration(sender, instance, created, **kwargs):
"""
Automatically create appropriate zone configuration (compliance, merchandising, or inventory)
based on the zone type when a Zone is created.
"""
if created:
try:
if instance.zone_type == 'compliance':
from .models import ZoneCompliance
ZoneCompliance.objects.get_or_create(
zone=instance,
defaults={
'checklist_items': [],
'requires_photo': True,
'frequency': 'Daily'
}
)
logger.info(f"Created ZoneCompliance for zone {instance.zone_name}")
elif instance.zone_type == 'merchandising':
from .models import ZoneMerchandising
ZoneMerchandising.objects.get_or_create(
zone=instance,
defaults={
'display_standards': []
}
)
logger.info(f"Created ZoneMerchandising for zone {instance.zone_name}")
elif instance.zone_type == 'inventory':
ZoneInventory.objects.get_or_create(
zone=instance,
defaults={
'count_frequency': 'Weekly',
'last_count_date': None
}
)
logger.info(f"Created ZoneInventory for zone {instance.zone_name}")
except Exception as e:
logger.error(f"Failed to create zone configuration for zone {instance.pk}: {e}")
Zone Type Branches
1. Compliance Zone:
if instance.zone_type == 'compliance':
ZoneCompliance.objects.get_or_create(
zone=instance,
defaults={
'checklist_items': [],
'requires_photo': True,
'frequency': 'Daily'
}
)
Default Configuration:
- Empty checklist items (to be populated later)
- Requires photo evidence by default
- Daily compliance frequency
2. Merchandising Zone:
elif instance.zone_type == 'merchandising':
ZoneMerchandising.objects.get_or_create(
zone=instance,
defaults={
'display_standards': []
}
)
Default Configuration:
- Empty display standards (to be populated later)
- Required products can be added via M2M relationship
3. Inventory Zone:
elif instance.zone_type == 'inventory':
ZoneInventory.objects.get_or_create(
zone=instance,
defaults={
'count_frequency': 'Weekly',
'last_count_date': None
}
)
Default Configuration:
- Weekly count frequency
- No last count date (never counted yet)
- Par levels can be added via relationship
Example: Zone Creation Flow
Scenario: Sanity webhook creates new "Coffee Bar" zone
Step 1: Zone Created via ZoneSerializer
zone = Zone.objects.create(
sanity_id="zone-abc",
zone_name="Coffee Bar",
store_location=store,
zone_type="inventory",
is_shown=True,
display_order=1
)
Step 2: Signal Fires (post_save with created=True)
# Signal handler detects zone_type == 'inventory'
# Automatically creates ZoneInventory
Step 3: ZoneInventory Auto-Created
zone_inventory = ZoneInventory.objects.create(
zone=zone,
count_frequency='Weekly',
last_count_date=None
)
Step 4: Zone Ready for Use
# Zone now has:
zone.zone_inventory # → ZoneInventory instance
zone.zone_inventory.par_levels # → Empty QuerySet (ready for par levels)
Why This Signal Exists
Problem Without Signal:
# Manual approach requires two steps:
zone = Zone.objects.create(zone_name="Coffee Bar", zone_type="inventory")
zone_inventory = ZoneInventory.objects.create(zone=zone) # Forgot this? Zone broken!
Solution With Signal:
# Signal approach is automatic:
zone = Zone.objects.create(zone_name="Coffee Bar", zone_type="inventory")
# zone.zone_inventory already exists automatically!
Benefits:
- Impossible to create zone without configuration
- Webhook processing simplified
- Manual zone creation simplified
- Consistent zone structure guaranteed
Interaction with ZoneSerializer
Important Note: In current implementation, ZoneSerializer ALSO creates zone type configurations in _process_zone_type_modules(). This creates a dual creation pattern:
- Signal-based creation: Auto-creates default configuration when Zone is saved
- Serializer-based creation: Explicitly creates configuration with webhook data
Resolution Pattern:
# Signal creates default
ZoneInventory.objects.get_or_create(zone=instance, defaults={...})
# Serializer updates with webhook data
zone_inventory = ZoneInventory.objects.get(zone=zone)
zone_inventory.count_frequency = webhook_data['count_frequency']
zone_inventory.save()
Why Both Exist:
- Signal ensures configuration exists (safety net)
- Serializer populates actual data from webhook
get_or_create()prevents duplicates- Webhook can override signal defaults
Signal Activation
Signals must be imported during Django app startup to be registered.
AppConfig Registration
From zones/apps.py:
from django.apps import AppConfig
class ZonesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'nextango.apps.zones'
def ready(self):
import nextango.apps.zones.signals # Register signal handlers
Critical: ready() method imports signals module, which registers all @receiver decorators.
Signal vs Service Layer
When Zones Uses Signals (Appropriate)
Infrastructure automation:
# Signal: Auto-create InventoryLevel when ParLevelItem created
@receiver(post_save, sender=ParLevelItem)
def create_inventory_level_for_par_level(...)
Why This Works:
- Side effect (creating related record)
- Should happen regardless of how ParLevelItem is created
- Not part of business logic flow
- Idempotent operation
When Zones Uses Service Layer (Also Appropriate)
Business logic operations:
# Service: Explicitly validate SKU assignments
zone_service = ZoneAssignmentService()
results = zone_service.validate_sku_assignments(zone_inventory)
Why This Works:
- Explicit operation requested by user/API
- Returns meaningful result data
- Part of business workflow
- Needs to be called on-demand
Hybrid Approach
Zones uses BOTH patterns appropriately:
- Signals for automatic infrastructure
- Services for explicit business logic
Example:
# Automatic: Signal creates InventoryLevel when ParLevelItem created
par_level = ParLevelItem.objects.create(...) # Signal fires automatically
# Explicit: Service validates all SKUs on-demand
POST /api/zones/inventory/{id}/validate_skus/ # Calls service explicitly
Testing Considerations
Signal Testing
Test signal behavior independently:
def test_par_level_creates_inventory_level():
"""Test that creating ParLevelItem auto-creates InventoryLevel."""
par_level = ParLevelItem.objects.create(
product=product,
variant_sku='DARK-12OZ',
zone_inventory=zone_inventory,
quantity=10
)
# Signal should have auto-created InventoryLevel
assert InventoryLevel.objects.filter(
product=product,
variant_sku='DARK-12OZ',
store=store
).exists()
Signal Mocking
Disable signals in tests if needed:
from django.test import override_settings
from django.db.models import signals
@override_settings()
def test_without_signals():
# Disconnect signal
signals.post_save.disconnect(
create_inventory_level_for_par_level,
sender=ParLevelItem
)
# Test logic without signal
par_level = ParLevelItem.objects.create(...)
# Reconnect signal
signals.post_save.connect(
create_inventory_level_for_par_level,
sender=ParLevelItem
)
Performance Considerations
Signal Overhead
Each ParLevelItem creation triggers:
- Database INSERT for ParLevelItem
- Signal fires
- Database SELECT (InventoryLevel.objects.get_or_create check)
- Database INSERT (if InventoryLevel doesn't exist)
Total: 2-3 database queries per ParLevelItem
Bulk Operations
Problem:
# Creating 100 par levels = 200-300 queries
for i in range(100):
ParLevelItem.objects.create(...) # Signal fires 100 times
Solution:
# Bulk create, then manually sync
par_levels = ParLevelItem.objects.bulk_create([...]) # Signals DON'T fire
# Manually trigger infrastructure creation
zone_service = ZoneAssignmentService()
zone_service.create_inventory_infrastructure_for_zone(zone)
Trade-off:
bulk_create()bypasses signals for performance- Manual sync required via service layer
- Good for large imports from Sanity
Related Documentation
Zones Domain
- README - Zones domain overview
- models.md - Zone, ParLevelItem, ZoneInventory models
- serializers.md - Zone serializers with webhook processing
- services.md - ZoneAssignmentService business logic
- views.md - Zone API endpoints
Related Domains
- products/models.md - InventoryLevel model
- web_auth/signals.md - Contrast: why web_auth DOESN'T use signals
Django Documentation
Summary: Zones signals automate infrastructure creation and cleanup for zone-driven inventory management, using Django signals appropriately for side effects (InventoryLevel creation) and infrastructure automation (zone configuration creation) while maintaining service layer for explicit business logic operations.