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):

  1. create_inventory_level_for_par_level - Auto-create inventory infrastructure
  2. cleanup_orphaned_inventory_level - Remove orphaned inventory records
  3. 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 _created flag 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:

  1. Signal-based creation: Auto-creates default configuration when Zone is saved
  2. 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:

  1. Database INSERT for ParLevelItem
  2. Signal fires
  3. Database SELECT (InventoryLevel.objects.get_or_create check)
  4. 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

Zones Domain

Related Domains

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.

Was this page helpful?