import uuid from django.conf import settings from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver from .models import ModelChangeLog, SecurityEvent from .signals import model_changed, security_event_occurred def _current_tenant(): from infrasynth.tenancy.context import get_current_tenant return get_current_tenant() def _get_excluded_models(): config = getattr(settings, "INFRASYNTH_AUDIT", {}) return set(config.get("EXCLUDED_MODELS", [])) def _get_excluded_fields(): config = getattr(settings, "INFRASYNTH_AUDIT", {}) return set(config.get("EXCLUDED_FIELDS", [])) def _get_excluded_model_fields(): config = getattr(settings, "INFRASYNTH_AUDIT", {}) return config.get("EXCLUDED_MODEL_FIELDS", {}) or {} def _excluded_fields_for(label: str) -> set[str]: """Global excluded fields plus the per-model exclusions for ``label``. Per-model exclusions let a model keep its audit trail while withholding a specific field — e.g. ``infrasynth_configs.ConfigValue`` is audited, but its (possibly encrypted) ``value`` payload never enters the log. """ fields = set(_get_excluded_fields()) fields.update(_get_excluded_model_fields().get(label, []) or []) return fields def _emit_model_changed(sender, log: ModelChangeLog) -> None: model_changed.send( sender=sender, tenant_id=str(log.tenant_id) if log.tenant_id else None, model_label=log.model_label, object_id=log.object_id, action=log.action, changes=log.changes, actor=log.actor, request_id=log.request_id, ) def _get_request_id(request=None): if request: return getattr(request, "request_id", "") or str(uuid.uuid4())[:8] return str(uuid.uuid4())[:8] def _store_enabled() -> bool: return bool(getattr(settings, "INFRASYNTH_AUDIT", {}).get("STORE_IN_DB", True)) @receiver(pre_save) def capture_previous_state(sender, instance, raw, **kwargs): """Snapshots the current DB row onto ``instance._previous_state`` before save. This is what lets :func:`track_model_change` compute an update diff without requiring every domain model to opt in. Creates (no existing row) are left alone. Excluded models are skipped so the audit tables never audit themselves. """ if raw or instance.pk is None: return config = getattr(settings, "INFRASYNTH_AUDIT", {}) if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): return if sender._meta.label in _get_excluded_models(): return manager = getattr(sender, "all_objects", None) or sender._base_manager try: previous = manager.filter(pk=instance.pk).first() except Exception: # noqa: BLE001 - audit must never break a write return if previous is not None: instance._previous_state = previous @receiver(post_save) def track_model_change(sender, instance, created, raw, **kwargs): if raw: return label = sender._meta.label if label in _get_excluded_models(): return config = getattr(settings, "INFRASYNTH_AUDIT", {}) if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): return if not _store_enabled(): return if created: log = ModelChangeLog.objects.create( tenant=_current_tenant(), model_label=label, object_id=str(instance.pk), action="create", changes=_get_created_changes(instance), actor=_get_actor_from_instance(instance), request_id=_get_request_id(), ) _emit_model_changed(sender, log) else: if hasattr(instance, "_previous_state"): changes = _compute_changes(instance._previous_state, instance) if changes: log = ModelChangeLog.objects.create( tenant=_current_tenant(), model_label=label, object_id=str(instance.pk), action="update", changes=changes, actor=_get_actor_from_instance(instance), request_id=_get_request_id(), ) _emit_model_changed(sender, log) @receiver(post_delete) def track_model_delete(sender, instance, **kwargs): label = sender._meta.label if label in _get_excluded_models(): return config = getattr(settings, "INFRASYNTH_AUDIT", {}) if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): return if not _store_enabled(): return log = ModelChangeLog.objects.create( tenant=_current_tenant(), model_label=label, object_id=str(instance.pk), action="delete", changes={}, actor=_get_actor_from_instance(instance), request_id=_get_request_id(), ) _emit_model_changed(sender, log) def _get_actor_from_instance(instance): for field in ["actor", "user", "usuario_creacion", "created_by", "uploaded_by"]: val = getattr(instance, field, None) if val is not None: return val return None def _get_created_changes(instance): excluded = _excluded_fields_for(instance._meta.label) changes = {} for field in instance._meta.get_fields(): if field.name in excluded: continue if hasattr(field, "serialize") and field.serialize: val = getattr(instance, field.name, None) if val is not None: changes[field.name] = [None, str(val)] return changes def _compute_changes(old, new): excluded = _excluded_fields_for(new._meta.label) changes = {} for field in new._meta.get_fields(): if field.name in excluded: continue if not hasattr(field, "column") or field.column is None: continue old_val = getattr(old, field.name, None) new_val = getattr(new, field.name, None) if old_val != new_val: changes[field.name] = [ str(old_val) if old_val is not None else None, str(new_val) if new_val is not None else None, ] return changes @receiver(security_event_occurred) def log_security_event(sender, **kwargs): config = getattr(settings, "INFRASYNTH_AUDIT", {}) if not config.get("ENABLE_SECURITY_EVENTS", True): return if not _store_enabled(): return SecurityEvent.objects.create( tenant=_current_tenant(), event_type=kwargs.get("event_type", "unknown"), actor=kwargs.get("actor"), ip_address=kwargs.get("ip_address"), metadata=kwargs.get("metadata", {}), request_id=_get_request_id(), )