django-field-audit 1.2.9__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-field-audit might be problematic. Click here for more details.

Files changed (33) hide show
  1. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/PKG-INFO +39 -1
  2. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/README.md +38 -0
  3. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/PKG-INFO +39 -1
  4. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/SOURCES.txt +2 -0
  5. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/__init__.py +1 -1
  6. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/field_audit.py +83 -0
  7. django_field_audit-1.3.0/field_audit/migrations/0003_alter_auditevent_change_context_and_more.py +32 -0
  8. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/models.py +53 -6
  9. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_field_audit.py +2 -0
  10. django_field_audit-1.3.0/tests/test_m2m.py +229 -0
  11. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_models.py +27 -0
  12. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/LICENSE +0 -0
  13. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/dependency_links.txt +0 -0
  14. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/top_level.txt +0 -0
  15. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/apps.py +0 -0
  16. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/auditors.py +0 -0
  17. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/const.py +0 -0
  18. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/management/__init__.py +0 -0
  19. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/management/commands/__init__.py +0 -0
  20. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/management/commands/bootstrap_field_audit_events.py +0 -0
  21. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/middleware.py +0 -0
  22. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/migrations/0001_initial.py +0 -0
  23. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
  24. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/migrations/__init__.py +0 -0
  25. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/utils.py +0 -0
  26. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/setup.cfg +0 -0
  27. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/setup.py +0 -0
  28. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_apps.py +0 -0
  29. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_auditors.py +0 -0
  30. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_bootstrap_field_audit_events.py +0 -0
  31. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_django_compat.py +0 -0
  32. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_middleware.py +0 -0
  33. {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: django-field-audit
3
- Version: 1.2.9
3
+ Version: 1.3.0
4
4
  Summary: Audit Field Changes on Django Models
5
5
  Home-page: https://github.com/dimagi/django-field-audit
6
6
  Maintainer: Joel Miller
@@ -177,6 +177,44 @@ details:
177
177
  are only committed to the database if audit events are successfully created
178
178
  and saved as well.
179
179
 
180
+ ### Auditing Many-to-Many fields
181
+
182
+ Many-to-Many field changes are automatically audited through Django signals when
183
+ included in the `@audit_fields` decorator. Changes to M2M relationships generate
184
+ audit events immediately without requiring `save()` calls.
185
+
186
+ ```python
187
+ # Example model with audited M2M field
188
+ @audit_fields("name", "title", "certifications")
189
+ class CrewMember(models.Model):
190
+ name = models.CharField(max_length=256)
191
+ title = models.CharField(max_length=64)
192
+ certifications = models.ManyToManyField('Certification', blank=True)
193
+ ```
194
+
195
+ #### Supported M2M operations
196
+
197
+ All standard M2M operations create audit events:
198
+
199
+ ```python
200
+ crew_member = CrewMember.objects.create(name='Test Pilot', title='Captain')
201
+ cert1 = Certification.objects.create(name='PPL', certification_type='Private')
202
+
203
+ crew_member.certifications.add(cert1) # Creates audit event
204
+ crew_member.certifications.remove(cert1) # Creates audit event
205
+ crew_member.certifications.set([cert1]) # Creates audit event
206
+ crew_member.certifications.clear() # Creates audit event
207
+ ```
208
+
209
+ #### M2M audit event structure
210
+
211
+ M2M changes use specific delta structures in audit events:
212
+
213
+ - **Add**: `{'certifications': {'add': [1, 2]}}`
214
+ - **Remove**: `{'certifications': {'remove': [2]}}`
215
+ - **Clear**: `{'certifications': {'remove': [1, 2]}}`
216
+ - **Create** / **Bootstrap**: `{'certifications': {'new': []}}`
217
+
180
218
  #### Bootstrap events for models with existing records
181
219
 
182
220
  In the scenario where auditing is enabled for a model with existing data, it can
@@ -140,6 +140,44 @@ details:
140
140
  are only committed to the database if audit events are successfully created
141
141
  and saved as well.
142
142
 
143
+ ### Auditing Many-to-Many fields
144
+
145
+ Many-to-Many field changes are automatically audited through Django signals when
146
+ included in the `@audit_fields` decorator. Changes to M2M relationships generate
147
+ audit events immediately without requiring `save()` calls.
148
+
149
+ ```python
150
+ # Example model with audited M2M field
151
+ @audit_fields("name", "title", "certifications")
152
+ class CrewMember(models.Model):
153
+ name = models.CharField(max_length=256)
154
+ title = models.CharField(max_length=64)
155
+ certifications = models.ManyToManyField('Certification', blank=True)
156
+ ```
157
+
158
+ #### Supported M2M operations
159
+
160
+ All standard M2M operations create audit events:
161
+
162
+ ```python
163
+ crew_member = CrewMember.objects.create(name='Test Pilot', title='Captain')
164
+ cert1 = Certification.objects.create(name='PPL', certification_type='Private')
165
+
166
+ crew_member.certifications.add(cert1) # Creates audit event
167
+ crew_member.certifications.remove(cert1) # Creates audit event
168
+ crew_member.certifications.set([cert1]) # Creates audit event
169
+ crew_member.certifications.clear() # Creates audit event
170
+ ```
171
+
172
+ #### M2M audit event structure
173
+
174
+ M2M changes use specific delta structures in audit events:
175
+
176
+ - **Add**: `{'certifications': {'add': [1, 2]}}`
177
+ - **Remove**: `{'certifications': {'remove': [2]}}`
178
+ - **Clear**: `{'certifications': {'remove': [1, 2]}}`
179
+ - **Create** / **Bootstrap**: `{'certifications': {'new': []}}`
180
+
143
181
  #### Bootstrap events for models with existing records
144
182
 
145
183
  In the scenario where auditing is enabled for a model with existing data, it can
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: django-field-audit
3
- Version: 1.2.9
3
+ Version: 1.3.0
4
4
  Summary: Audit Field Changes on Django Models
5
5
  Home-page: https://github.com/dimagi/django-field-audit
6
6
  Maintainer: Joel Miller
@@ -177,6 +177,44 @@ details:
177
177
  are only committed to the database if audit events are successfully created
178
178
  and saved as well.
179
179
 
180
+ ### Auditing Many-to-Many fields
181
+
182
+ Many-to-Many field changes are automatically audited through Django signals when
183
+ included in the `@audit_fields` decorator. Changes to M2M relationships generate
184
+ audit events immediately without requiring `save()` calls.
185
+
186
+ ```python
187
+ # Example model with audited M2M field
188
+ @audit_fields("name", "title", "certifications")
189
+ class CrewMember(models.Model):
190
+ name = models.CharField(max_length=256)
191
+ title = models.CharField(max_length=64)
192
+ certifications = models.ManyToManyField('Certification', blank=True)
193
+ ```
194
+
195
+ #### Supported M2M operations
196
+
197
+ All standard M2M operations create audit events:
198
+
199
+ ```python
200
+ crew_member = CrewMember.objects.create(name='Test Pilot', title='Captain')
201
+ cert1 = Certification.objects.create(name='PPL', certification_type='Private')
202
+
203
+ crew_member.certifications.add(cert1) # Creates audit event
204
+ crew_member.certifications.remove(cert1) # Creates audit event
205
+ crew_member.certifications.set([cert1]) # Creates audit event
206
+ crew_member.certifications.clear() # Creates audit event
207
+ ```
208
+
209
+ #### M2M audit event structure
210
+
211
+ M2M changes use specific delta structures in audit events:
212
+
213
+ - **Add**: `{'certifications': {'add': [1, 2]}}`
214
+ - **Remove**: `{'certifications': {'remove': [2]}}`
215
+ - **Clear**: `{'certifications': {'remove': [1, 2]}}`
216
+ - **Create** / **Bootstrap**: `{'certifications': {'new': []}}`
217
+
180
218
  #### Bootstrap events for models with existing records
181
219
 
182
220
  In the scenario where auditing is enabled for a model with existing data, it can
@@ -19,12 +19,14 @@ field_audit/management/commands/__init__.py
19
19
  field_audit/management/commands/bootstrap_field_audit_events.py
20
20
  field_audit/migrations/0001_initial.py
21
21
  field_audit/migrations/0002_add_is_bootstrap_column.py
22
+ field_audit/migrations/0003_alter_auditevent_change_context_and_more.py
22
23
  field_audit/migrations/__init__.py
23
24
  tests/test_apps.py
24
25
  tests/test_auditors.py
25
26
  tests/test_bootstrap_field_audit_events.py
26
27
  tests/test_django_compat.py
27
28
  tests/test_field_audit.py
29
+ tests/test_m2m.py
28
30
  tests/test_middleware.py
29
31
  tests/test_models.py
30
32
  tests/test_utils.py
@@ -1,3 +1,3 @@
1
1
  from .field_audit import audit_fields # noqa: F401
2
2
 
3
- __version__ = "1.2.9"
3
+ __version__ = "1.3.0"
@@ -2,6 +2,7 @@ import contextvars
2
2
  from functools import wraps
3
3
 
4
4
  from django.db import models, router, transaction
5
+ from django.db.models.signals import m2m_changed
5
6
 
6
7
  from .utils import get_fqcn
7
8
 
@@ -63,6 +64,8 @@ def audit_fields(*field_names, class_path=None, audit_special_queryset_writes=Fa
63
64
  cls.save = _decorate_db_write(cls.save)
64
65
  cls.delete = _decorate_db_write(cls.delete)
65
66
  cls.refresh_from_db = _decorate_refresh_from_db(cls.refresh_from_db)
67
+
68
+ _register_m2m_signals(cls, field_names)
66
69
  _audited_models[cls] = get_fqcn(cls) if class_path is None else class_path # noqa: E501
67
70
  return cls
68
71
  if not field_names:
@@ -154,6 +157,86 @@ def _decorate_refresh_from_db(func):
154
157
  return wrapper
155
158
 
156
159
 
160
+ def _register_m2m_signals(cls, field_names):
161
+ """Register m2m_changed signal handlers for ManyToManyFields.
162
+
163
+ :param cls: The model class being audited
164
+ :param field_names: List of field names that are being audited
165
+ """
166
+ for field_name in field_names:
167
+ try:
168
+ field = cls._meta.get_field(field_name)
169
+ if isinstance(field, models.ManyToManyField):
170
+ m2m_changed.connect(
171
+ _m2m_changed_handler,
172
+ sender=field.remote_field.through,
173
+ weak=False
174
+ )
175
+ except Exception:
176
+ # If field doesn't exist or isn't a M2M field, continue
177
+ continue
178
+
179
+
180
+ def _m2m_changed_handler(sender, instance, action, pk_set, **kwargs):
181
+ """Signal handler for m2m_changed to audit ManyToManyField changes.
182
+
183
+ :param sender: The intermediate model class for the ManyToManyField
184
+ :param instance: The instance whose many-to-many relation is updated
185
+ :param action: A string indicating the type of update
186
+ :param pk_set: For add/remove actions, set of primary key values
187
+ """
188
+ from .models import AuditEvent
189
+
190
+ if action not in ('post_add', 'post_remove', 'post_clear', 'pre_clear'):
191
+ return
192
+
193
+ if type(instance) not in _audited_models:
194
+ return
195
+
196
+ # Find which M2M field this change relates to
197
+ m2m_field = None
198
+ field_name = None
199
+ for field in instance._meta.get_fields():
200
+ if (
201
+ isinstance(field, models.ManyToManyField) and
202
+ hasattr(field, 'remote_field') and
203
+ field.remote_field.through == sender
204
+ ):
205
+ m2m_field = field
206
+ field_name = field.name
207
+ break
208
+
209
+ if not m2m_field or field_name not in AuditEvent.field_names(instance):
210
+ return
211
+
212
+ if action == 'pre_clear':
213
+ # `pk_set` not supplied for clear actions. Determine initial values
214
+ # in the `pre_clear` event
215
+ AuditEvent.attach_initial_m2m_values(instance, field_name)
216
+ return
217
+
218
+ if action == 'post_clear':
219
+ initial_values = AuditEvent.get_initial_m2m_values(instance, field_name)
220
+ if not initial_values:
221
+ return
222
+ delta = {field_name: {'remove': initial_values}}
223
+ else:
224
+ if not pk_set:
225
+ # the change was a no-op
226
+ return
227
+ delta_key = 'add' if action == 'post_add' else 'remove'
228
+ delta = {field_name: {delta_key: list(pk_set)}}
229
+
230
+ req = request.get()
231
+ event = AuditEvent.create_audit_event(
232
+ instance.pk, instance.__class__, delta, False, False, req
233
+ )
234
+ if event is not None:
235
+ event.save()
236
+
237
+ AuditEvent.clear_initial_m2m_field_values(instance, field_name)
238
+
239
+
157
240
  def get_audited_models():
158
241
  return _audited_models.copy()
159
242
 
@@ -0,0 +1,32 @@
1
+ # Generated by Django 5.1.6 on 2025-06-27 15:11
2
+
3
+ import django.core.serializers.json
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('field_audit', '0002_add_is_bootstrap_column'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AlterField(
15
+ model_name='auditevent',
16
+ name='change_context',
17
+ field=models.JSONField(
18
+ encoder=django.core.serializers.json.DjangoJSONEncoder),
19
+ ),
20
+ migrations.AlterField(
21
+ model_name='auditevent',
22
+ name='delta',
23
+ field=models.JSONField(
24
+ encoder=django.core.serializers.json.DjangoJSONEncoder),
25
+ ),
26
+ migrations.AlterField(
27
+ model_name='auditevent',
28
+ name='object_pk',
29
+ field=models.JSONField(
30
+ encoder=django.core.serializers.json.DjangoJSONEncoder),
31
+ ),
32
+ ]
@@ -3,6 +3,7 @@ from functools import wraps
3
3
  from itertools import islice
4
4
 
5
5
  from django.conf import settings
6
+ from django.core.serializers.json import DjangoJSONEncoder
6
7
  from django.db import models, transaction
7
8
  from django.db.models import Expression
8
9
  from django.utils import timezone
@@ -203,12 +204,12 @@ def get_date():
203
204
  class AuditEvent(models.Model):
204
205
  event_date = models.DateTimeField(default=get_date, db_index=True)
205
206
  object_class_path = models.CharField(db_index=True, max_length=255)
206
- object_pk = models.JSONField()
207
- change_context = models.JSONField()
207
+ object_pk = models.JSONField(encoder=DjangoJSONEncoder)
208
+ change_context = models.JSONField(encoder=DjangoJSONEncoder)
208
209
  is_create = models.BooleanField(default=False)
209
210
  is_delete = models.BooleanField(default=False)
210
211
  is_bootstrap = models.BooleanField(default=False)
211
- delta = models.JSONField()
212
+ delta = models.JSONField(encoder=DjangoJSONEncoder)
212
213
 
213
214
  objects = get_manager("AUDITEVENT_MANAGER", DefaultAuditEventManager)
214
215
 
@@ -226,6 +227,7 @@ class AuditEvent(models.Model):
226
227
 
227
228
  ATTACH_FIELD_NAMES_AT = "__field_audit_field_names"
228
229
  ATTACH_INIT_VALUES_AT = "__field_audit_init_values"
230
+ ATTACH_INIT_M2M_VALUES_AT = "__field_audit_init_m2m_values"
229
231
 
230
232
  @classmethod
231
233
  def attach_field_names(cls, model_class, field_names):
@@ -245,13 +247,19 @@ class AuditEvent(models.Model):
245
247
  return getattr(model_class, cls.ATTACH_FIELD_NAMES_AT)
246
248
 
247
249
  @staticmethod
248
- def get_field_value(instance, field_name):
250
+ def get_field_value(instance, field_name, bootstrap=False):
249
251
  """Returns the database value of a field on ``instance``.
250
252
 
251
253
  :param instance: an instance of a Django model
252
254
  :param field_name: name of a field on ``instance``
253
255
  """
254
256
  field = instance._meta.get_field(field_name)
257
+
258
+ if isinstance(field, models.ManyToManyField):
259
+ # ManyToManyField handled by Django signals
260
+ if bootstrap:
261
+ return AuditEvent.get_m2m_field_value(instance, field_name)
262
+ return []
255
263
  return field.to_python(field.value_from_object(instance))
256
264
 
257
265
  @classmethod
@@ -275,6 +283,44 @@ class AuditEvent(models.Model):
275
283
  init_values = {f: cls.get_field_value(instance, f) for f in field_names}
276
284
  setattr(instance, cls.ATTACH_INIT_VALUES_AT, init_values)
277
285
 
286
+ @classmethod
287
+ def attach_initial_m2m_values(cls, instance, field_name):
288
+ field = instance._meta.get_field(field_name)
289
+ if not isinstance(field, models.ManyToManyField):
290
+ return None
291
+
292
+ values = cls.get_m2m_field_value(instance, field_name)
293
+ init_values = getattr(
294
+ instance, cls.ATTACH_INIT_M2M_VALUES_AT, None
295
+ ) or {}
296
+ init_values.update({field_name: values})
297
+ setattr(instance, cls.ATTACH_INIT_M2M_VALUES_AT, init_values)
298
+
299
+ @classmethod
300
+ def get_initial_m2m_values(cls, instance, field_name):
301
+ init_values = getattr(
302
+ instance, cls.ATTACH_INIT_M2M_VALUES_AT, None
303
+ ) or {}
304
+ return init_values.get(field_name)
305
+
306
+ @classmethod
307
+ def clear_initial_m2m_field_values(cls, instance, field_name):
308
+ init_values = getattr(
309
+ instance, cls.ATTACH_INIT_M2M_VALUES_AT, None
310
+ ) or {}
311
+ init_values.pop(field_name, None)
312
+ setattr(instance, cls.ATTACH_INIT_M2M_VALUES_AT, init_values)
313
+
314
+ @classmethod
315
+ def get_m2m_field_value(cls, instance, field_name):
316
+ if instance.pk is None:
317
+ # Instance is not saved, return empty list
318
+ return []
319
+ else:
320
+ # Instance is saved, we can access the related objects
321
+ related_manager = getattr(instance, field_name)
322
+ return list(related_manager.values_list('pk', flat=True))
323
+
278
324
  @classmethod
279
325
  def reset_initial_values(cls, instance):
280
326
  """Returns the previously attached "initial values" and attaches new
@@ -396,7 +442,6 @@ class AuditEvent(models.Model):
396
442
  object_pk = instance.pk
397
443
 
398
444
  delta = cls.get_delta_from_instance(instance, is_create, is_delete)
399
-
400
445
  if delta:
401
446
  return cls.create_audit_event(object_pk, type(instance), delta,
402
447
  is_create, is_delete, request)
@@ -471,7 +516,9 @@ class AuditEvent(models.Model):
471
516
  for instance in iter_records():
472
517
  delta = {}
473
518
  for field_name in field_names:
474
- value = cls.get_field_value(instance, field_name)
519
+ value = cls.get_field_value(
520
+ instance, field_name, bootstrap=True
521
+ )
475
522
  delta[field_name] = {"new": value}
476
523
  yield cls(
477
524
  object_class_path=object_class_path,
@@ -21,6 +21,7 @@ from field_audit.models import AuditEvent, AuditingManager
21
21
  from .models import (
22
22
  Aerodrome,
23
23
  Aircraft,
24
+ Certification,
24
25
  CrewMember,
25
26
  Flight,
26
27
  SimpleModel,
@@ -123,6 +124,7 @@ class TestFieldAudit(TestCase):
123
124
  Aerodrome,
124
125
  Aircraft,
125
126
  CrewMember,
127
+ Certification,
126
128
  Flight,
127
129
  SimpleModel,
128
130
  ModelWithAuditingManager,
@@ -0,0 +1,229 @@
1
+ from unittest.mock import ANY, patch
2
+
3
+ from django.test import TestCase
4
+
5
+ from field_audit.const import BOOTSTRAP_BATCH_SIZE
6
+ from field_audit.models import AuditEvent
7
+
8
+
9
+ class TestAuditEventM2M(TestCase):
10
+ def test_manytomany_field_auditing(self):
11
+ """Test ManyToManyField relationships are properly handled."""
12
+ from .models import CrewMember, Certification
13
+
14
+ # Create some certifications
15
+ cert1 = Certification.objects.create(
16
+ name='Private Pilot License',
17
+ certification_type='PPL'
18
+ )
19
+ cert2 = Certification.objects.create(
20
+ name='Instrument Rating',
21
+ certification_type='IR'
22
+ )
23
+
24
+ # Create a crew member
25
+ crew_member = CrewMember.objects.create(
26
+ name='Test Pilot',
27
+ title='Captain',
28
+ flight_hours=1500.0
29
+ )
30
+
31
+ # Add certifications to crew member
32
+ crew_member.certifications.set([cert1, cert2])
33
+
34
+ events = AuditEvent.objects.by_model(CrewMember).order_by('event_date')
35
+ self.assertEqual(events.count(), 2)
36
+
37
+ # Check the create event
38
+ create_event = events.filter(is_create=True).first()
39
+ self.assertIsNotNone(create_event)
40
+
41
+ # Delta should contain field values including ManyToMany field
42
+ delta = create_event.delta
43
+ self.assertIn('name', delta)
44
+ self.assertIn('title', delta)
45
+ self.assertIn('flight_hours', delta)
46
+ self.assertEqual(delta['certifications']['new'], [])
47
+
48
+ update_event = events.filter(is_create=False).first()
49
+ self.assertIsNotNone(update_event)
50
+ delta = update_event.delta
51
+ self.assertEqual(
52
+ set(delta['certifications']['add']), {cert1.id, cert2.id}
53
+ )
54
+
55
+ def test_manytomany_field_modification_auditing(self):
56
+ """Test that ManyToManyField changes are properly audited."""
57
+ from .models import CrewMember, Certification
58
+
59
+ cert1 = Certification.objects.create(
60
+ name='PPL', certification_type='Private'
61
+ )
62
+ cert2 = Certification.objects.create(
63
+ name='IR', certification_type='Instrument'
64
+ )
65
+ cert3 = Certification.objects.create(
66
+ name='CPL', certification_type='Commercial'
67
+ )
68
+
69
+ crew_member = CrewMember.objects.create(
70
+ name='Test Pilot',
71
+ title='Captain',
72
+ flight_hours=1500.0
73
+ )
74
+ crew_member.certifications.set([cert1, cert2])
75
+
76
+ # Modify certifications (remove cert2, add cert3)
77
+ crew_member.certifications.set([cert1, cert3])
78
+
79
+ events = AuditEvent.objects.by_model(CrewMember).order_by('event_date')
80
+ latest_events = events.filter(is_create=False, is_delete=False)
81
+ self.assertEqual(latest_events.count(), 3)
82
+ certification_deltas = [
83
+ event.delta['certifications'] for event in latest_events
84
+ ]
85
+ self.assertEqual(
86
+ [list(delta) for delta in certification_deltas],
87
+ [['add'], ['remove'], ['add']]
88
+ )
89
+ self.assertEqual(
90
+ [set(list(delta.values())[0]) for delta in certification_deltas],
91
+ [{cert1.id, cert2.id}, {cert2.id}, {cert3.id}]
92
+ )
93
+
94
+ def test_manytomany_field_clear_auditing(self):
95
+ """Test that clearing ManyToManyField is properly audited."""
96
+ from .models import CrewMember, Certification
97
+
98
+ # Create certifications and crew member
99
+ cert1 = Certification.objects.create(
100
+ name='PPL', certification_type='Private'
101
+ )
102
+ cert2 = Certification.objects.create(
103
+ name='IR', certification_type='Instrument'
104
+ )
105
+
106
+ crew_member = CrewMember.objects.create(
107
+ name='Test Pilot',
108
+ title='Captain',
109
+ flight_hours=1500.0
110
+ )
111
+ crew_member.certifications.set([cert1, cert2])
112
+
113
+ initial_events_count = AuditEvent.objects.by_model(
114
+ CrewMember
115
+ ).count()
116
+
117
+ # Clear all certifications
118
+ crew_member.certifications.clear()
119
+
120
+ events = AuditEvent.objects.by_model(CrewMember).order_by('event_date')
121
+ self.assertEqual(events.count(), initial_events_count + 1)
122
+
123
+ latest_event = events.filter(
124
+ is_create=False, is_delete=False
125
+ ).last()
126
+ delta = latest_event.delta
127
+ self.assertEqual(
128
+ set(delta['certifications']['remove']), {cert1.id, cert2.id}
129
+ )
130
+
131
+ def test_manytomany_field_realtime_auditing_with_add_remove(self):
132
+ """Test M2M changes create audit events immediately via signals."""
133
+ from .models import CrewMember, Certification
134
+
135
+ # Create certifications and crew member
136
+ cert1 = Certification.objects.create(
137
+ name='PPL', certification_type='Private'
138
+ )
139
+ cert2 = Certification.objects.create(
140
+ name='IR', certification_type='Instrument'
141
+ )
142
+
143
+ crew_member = CrewMember.objects.create(
144
+ name='Test Pilot',
145
+ title='Captain',
146
+ flight_hours=1500.0
147
+ )
148
+
149
+ initial_events_count = AuditEvent.objects.by_model(
150
+ CrewMember
151
+ ).count()
152
+ self.assertEqual(initial_events_count, 1)
153
+
154
+ # Test direct add() - should create audit event immediately
155
+ crew_member.certifications.add(cert1, cert2)
156
+
157
+ # Check audit event was created immediately (without save())
158
+ events = AuditEvent.objects.by_model(CrewMember).order_by(
159
+ 'event_date'
160
+ )
161
+
162
+ new_events = list(events[initial_events_count:])
163
+ self.assertEqual(
164
+ len(new_events), 1, "M2M add() should create audit event"
165
+ )
166
+ latest_event = new_events[-1]
167
+ self.assertIn('certifications', latest_event.delta)
168
+ self.assertEqual(
169
+ set(latest_event.delta['certifications']['add']),
170
+ {cert1.id, cert2.id}
171
+ )
172
+
173
+ # Test direct remove() - should create audit event immediately
174
+ current_events_count = events.count()
175
+ crew_member.certifications.remove(cert1)
176
+
177
+ events = AuditEvent.objects.by_model(CrewMember).order_by(
178
+ 'event_date'
179
+ )
180
+ self.assertEqual(
181
+ events.count(), current_events_count + 1,
182
+ "M2M remove() should create audit event"
183
+ )
184
+
185
+ # Check the remove event
186
+ latest_event = events.last()
187
+ self.assertIn('certifications', latest_event.delta)
188
+ self.assertEqual(
189
+ set(latest_event.delta['certifications']['remove']), {cert1.id}
190
+ )
191
+
192
+
193
+ class TestAuditEventBootstrappingM2M(TestCase):
194
+
195
+ @classmethod
196
+ def setUpClass(cls):
197
+ super().setUpClass()
198
+ from tests.models import Certification, CrewMember
199
+ cls.cert1 = Certification.objects.create(
200
+ name='PPL', certification_type='Private'
201
+ )
202
+ cls.cert2 = Certification.objects.create(
203
+ name='IR', certification_type='Instrument'
204
+ )
205
+
206
+ crew_member = CrewMember.objects.create(
207
+ name='Test Pilot',
208
+ title='Captain',
209
+ flight_hours=1500.0
210
+ )
211
+ crew_member.certifications.set([cls.cert1, cls.cert2])
212
+
213
+ def test_bootstrap_existing_model_records_m2m(self):
214
+ from tests.models import CrewMember
215
+ self.assertEqual([], list(AuditEvent.objects.filter(is_bootstrap=True)))
216
+ with patch.object(AuditEvent.objects, "bulk_create",
217
+ side_effect=AuditEvent.objects.bulk_create) as mock:
218
+ created_count = AuditEvent.bootstrap_existing_model_records(
219
+ CrewMember,
220
+ ['certifications'],
221
+ )
222
+ mock.assert_called_once_with(ANY, batch_size=BOOTSTRAP_BATCH_SIZE)
223
+ bootstrap_events = AuditEvent.objects.filter(is_bootstrap=True)
224
+ self.assertEqual(len(bootstrap_events), created_count)
225
+ event = bootstrap_events[0]
226
+ self.assertEqual(
227
+ set(event.delta['certifications']['new']),
228
+ {self.cert1.id, self.cert2.id}
229
+ )
@@ -398,6 +398,33 @@ class TestAuditEvent(TestCase):
398
398
  capt = CrewMember(title=CleverTitle("Captain"))
399
399
  self.assertEqual("Captain", AuditEvent.get_field_value(capt, "title"))
400
400
 
401
+ def test_decimal_field_serialization(self):
402
+ """Test that DecimalField values are properly handled in audit events.
403
+ """
404
+ from decimal import Decimal
405
+
406
+ # Create a CrewMember with a decimal field
407
+ CrewMember.objects.create(
408
+ name='Test Pilot',
409
+ title='Captain',
410
+ flight_hours=Decimal('1234.5678')
411
+ )
412
+
413
+ # Check that the audit event was created successfully
414
+ events = AuditEvent.objects.filter(
415
+ object_class_path='tests.models.CrewMember')
416
+ self.assertEqual(events.count(), 1)
417
+
418
+ event = events.first()
419
+
420
+ # The decimal value should be serialized as a string in the delta
421
+ flight_hours_new = event.delta["flight_hours"]["new"]
422
+ self.assertIsInstance(flight_hours_new, str)
423
+ self.assertEqual(flight_hours_new, '1234.5678')
424
+
425
+ # Verify the string can be converted back to Decimal
426
+ self.assertEqual(Decimal(flight_hours_new), Decimal('1234.5678'))
427
+
401
428
  def test_event_date_default(self):
402
429
  event = AuditEvent.objects.create(**EVENT_REQ_FIELDS)
403
430
  self.assertLess(