227 lines
6.9 KiB
Python
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'
|
|
)
|