Skip to content

Commit fcc4d54

Browse files
authored
Merge pull request #40 from dimagi/sk/serialization
Add ManyToManyField serialization support
2 parents 5deab05 + b1071d4 commit fcc4d54

File tree

6 files changed

+411
-4
lines changed

6 files changed

+411
-4
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,44 @@ details:
140140
are only committed to the database if audit events are successfully created
141141
and saved as well.
142142

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+
143181
#### Bootstrap events for models with existing records
144182

145183
In the scenario where auditing is enabled for a model with existing data, it can

field_audit/field_audit.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from functools import wraps
33

44
from django.db import models, router, transaction
5+
from django.db.models.signals import m2m_changed
56

67
from .utils import get_fqcn
78

@@ -63,6 +64,8 @@ def wrapper(cls):
6364
cls.save = _decorate_db_write(cls.save)
6465
cls.delete = _decorate_db_write(cls.delete)
6566
cls.refresh_from_db = _decorate_refresh_from_db(cls.refresh_from_db)
67+
68+
_register_m2m_signals(cls, field_names)
6669
_audited_models[cls] = get_fqcn(cls) if class_path is None else class_path # noqa: E501
6770
return cls
6871
if not field_names:
@@ -154,6 +157,86 @@ def wrapper(self, using=None, fields=None, **kwargs):
154157
return wrapper
155158

156159

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+
157240
def get_audited_models():
158241
return _audited_models.copy()
159242

field_audit/models.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ class Meta:
227227

228228
ATTACH_FIELD_NAMES_AT = "__field_audit_field_names"
229229
ATTACH_INIT_VALUES_AT = "__field_audit_init_values"
230+
ATTACH_INIT_M2M_VALUES_AT = "__field_audit_init_m2m_values"
230231

231232
@classmethod
232233
def attach_field_names(cls, model_class, field_names):
@@ -246,13 +247,19 @@ def field_names(cls, model_class):
246247
return getattr(model_class, cls.ATTACH_FIELD_NAMES_AT)
247248

248249
@staticmethod
249-
def get_field_value(instance, field_name):
250+
def get_field_value(instance, field_name, bootstrap=False):
250251
"""Returns the database value of a field on ``instance``.
251252
252253
:param instance: an instance of a Django model
253254
:param field_name: name of a field on ``instance``
254255
"""
255256
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 []
256263
return field.to_python(field.value_from_object(instance))
257264

258265
@classmethod
@@ -276,6 +283,44 @@ def attach_initial_values(cls, instance):
276283
init_values = {f: cls.get_field_value(instance, f) for f in field_names}
277284
setattr(instance, cls.ATTACH_INIT_VALUES_AT, init_values)
278285

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+
279324
@classmethod
280325
def reset_initial_values(cls, instance):
281326
"""Returns the previously attached "initial values" and attaches new
@@ -397,7 +442,6 @@ def make_audit_event_from_instance(cls, instance, is_create, is_delete,
397442
object_pk = instance.pk
398443

399444
delta = cls.get_delta_from_instance(instance, is_create, is_delete)
400-
401445
if delta:
402446
return cls.create_audit_event(object_pk, type(instance), delta,
403447
is_create, is_delete, request)
@@ -472,7 +516,9 @@ def iter_events():
472516
for instance in iter_records():
473517
delta = {}
474518
for field_name in field_names:
475-
value = cls.get_field_value(instance, field_name)
519+
value = cls.get_field_value(
520+
instance, field_name, bootstrap=True
521+
)
476522
delta[field_name] = {"new": value}
477523
yield cls(
478524
object_class_path=object_class_path,

tests/models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
ForeignKey,
1111
IntegerField,
1212
JSONField,
13+
ManyToManyField,
1314
)
1415

1516
from field_audit import audit_fields
@@ -41,12 +42,13 @@ def save(self, *args, **kwargs):
4142
super().save(*args, **kwargs)
4243

4344

44-
@audit_fields("name", "title", "flight_hours")
45+
@audit_fields("name", "title", "flight_hours", "certifications")
4546
class CrewMember(Model):
4647
id = AutoField(primary_key=True)
4748
name = CharField(max_length=256)
4849
title = CharField(max_length=64)
4950
flight_hours = DecimalField(max_digits=10, decimal_places=4, default=0.0)
51+
certifications = ManyToManyField('Certification', blank=True)
5052

5153

5254
@audit_fields("tail_number", "make_model", "operated_by")
@@ -109,3 +111,10 @@ class PkAuto(Model):
109111
@audit_fields("id")
110112
class PkJson(Model):
111113
id = JSONField(primary_key=True)
114+
115+
116+
@audit_fields("name", "certification_type")
117+
class Certification(Model):
118+
id = AutoField(primary_key=True)
119+
name = CharField(max_length=128)
120+
certification_type = CharField(max_length=64)

tests/test_field_audit.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .models import (
2222
Aerodrome,
2323
Aircraft,
24+
Certification,
2425
CrewMember,
2526
Flight,
2627
SimpleModel,
@@ -123,6 +124,7 @@ def test_get_audited_models(self):
123124
Aerodrome,
124125
Aircraft,
125126
CrewMember,
127+
Certification,
126128
Flight,
127129
SimpleModel,
128130
ModelWithAuditingManager,

0 commit comments

Comments
 (0)