loko/streetup/assets/signals.py
2026-07-22 14:48:40 +02:00

227 lines
6.9 KiB
Python

"""
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'
)