""" Signals for tracking asset modifications. This module automatically captures changes to assets and stores them in AssetHistory. """ from django.db.models.signals import pre_save, post_save, pre_delete from django.dispatch import receiver from django.contrib.contenttypes.models import ContentType from django.contrib.gis.geos import GEOSGeometry from .models import AssetHistory from .models.core import AbstractAsset import json # Small numeric tolerance to ignore insignificant float/geometry differences NUMERIC_EPSILON = 1e-7 def _serialize_value(value): """Serialize a value to string for storage""" if value is None: return None # Handle ForeignKey objects if hasattr(value, 'pk'): return str(value.pk) # Handle datetime objects if hasattr(value, 'isoformat'): return value.isoformat() # Handle geometry/GeoJSON if hasattr(value, 'geojson'): return value.geojson return str(value) def _get_tracked_fields(): """Return list of fields to track for changes""" return [ 'code', 'name_fr', 'name_nl', 'status', 'installation_date', 'uninstallation_date', 'serial_number', 'warranty_duration', 'last_inspection_date', 'last_preventive_date', 'last_corrective_date', 'last_survey_date', 'category', 'brand', 'model', 'location', 'structure', 'lon', 'lat', 'geom', 'geojson', 'replaced_by', 'marked_as_removed_date', 'marked_as_removed_by', ] def _values_are_equivalent(old_value, new_value, field_name): """Return True if values are effectively equal (tolerance for floats/geometry).""" # Exact equality or both None/empty if old_value is None and new_value is None: return True if old_value is None or new_value is None: return False if old_value == new_value: return True # Floats (lon/lat or other float fields) if isinstance(old_value, (float, int)) and isinstance(new_value, (float, int)): return abs(old_value - new_value) < NUMERIC_EPSILON # Geometry equality with tolerance if hasattr(old_value, 'geom_type') and hasattr(new_value, 'geom_type'): try: return old_value.equals_exact(new_value, NUMERIC_EPSILON) or old_value.equals(new_value) except Exception: # Fallback to WKT comparison if equals_exact fails return old_value.wkt == new_value.wkt # GeoJSON string comparison (try geometry equivalence) if field_name == 'geojson' and isinstance(old_value, str) and isinstance(new_value, str): try: old_geom = GEOSGeometry(old_value) new_geom = GEOSGeometry(new_value) return old_geom.equals_exact(new_geom, NUMERIC_EPSILON) or old_geom.equals(new_geom) except Exception: return old_value == new_value return False def _should_track_asset(instance): """Check if an asset should be tracked""" # Check if it's an AbstractAsset subclass if not isinstance(instance, AbstractAsset): return False # Don't track abstract models if instance._meta.abstract: return False return True @receiver(pre_save) def capture_asset_changes(sender, instance, **kwargs): """ Capture changes before save. Store the old state in a temporary attribute for post_save processing. """ if not _should_track_asset(instance): return # Only track if instance already exists (not a new creation) if instance.pk: try: old_instance = sender.objects.get(pk=instance.pk) instance._old_instance = old_instance except sender.DoesNotExist: instance._old_instance = None else: instance._old_instance = None @receiver(post_save) def save_asset_changes(sender, instance, created, **kwargs): """ Save changes to AssetHistory after save. """ if not _should_track_asset(instance): return # Get the user from the request (if available) # This should be set in the view/API when updating user = getattr(instance, '_history_user', None) if created: # Track creation content_type = ContentType.objects.get_for_model(instance) AssetHistory.objects.create( content_type=content_type, object_id=instance.pk, user=user, field_name='__created__', old_value=None, new_value=_serialize_value(instance.code), action_type='create' ) else: # Track modifications old_instance = getattr(instance, '_old_instance', None) if not old_instance: return content_type = ContentType.objects.get_for_model(instance) tracked_fields = _get_tracked_fields() for field_name in tracked_fields: # Check if field exists on this model if not hasattr(instance, field_name): continue old_value = getattr(old_instance, field_name, None) new_value = getattr(instance, field_name, None) # Skip if values are effectively the same (tolerance for floats/geometry) if _values_are_equivalent(old_value, new_value, field_name): continue # Serialize for comparison old_serialized = _serialize_value(old_value) new_serialized = _serialize_value(new_value) # Only track if value changed if old_serialized != new_serialized: # Determine action type action_type = 'update' if field_name == 'status': action_type = 'status_change' AssetHistory.objects.create( content_type=content_type, object_id=instance.pk, user=user, field_name=field_name, old_value=old_serialized, new_value=new_serialized, action_type=action_type ) # Clean up temporary attribute if hasattr(instance, '_old_instance'): delattr(instance, '_old_instance') @receiver(pre_delete) def track_asset_deletion(sender, instance, **kwargs): """ Track asset deletion. """ if not _should_track_asset(instance): return # Get the user from the request (if available) user = getattr(instance, '_history_user', None) content_type = ContentType.objects.get_for_model(instance) AssetHistory.objects.create( content_type=content_type, object_id=instance.pk, user=user, field_name='__deleted__', old_value=_serialize_value(instance.code), new_value=None, action_type='delete' )