django-field-audit 1.2.9__py2.py3-none-any.whl → 1.3.0__py2.py3-none-any.whl

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.

@@ -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
@@ -1,19 +1,20 @@
1
- field_audit/__init__.py,sha256=ukvhgwNNpEmH3wqrM6bBaNi4fH-ORfPzcyDbeWUcvnA,75
1
+ field_audit/__init__.py,sha256=nqzFkr4OZWLY-xTZ5-uZhnh-Rp6iEV2tP0jPeExcq0s,75
2
2
  field_audit/apps.py,sha256=04NYTi54zEuYPAhxEvsS61hmoPMIMVfPQKi4DOa9nE0,265
3
3
  field_audit/auditors.py,sha256=V5Ese7w4V_WTLklIm5EOsjOJ3t4Iwp69AmgwxNXD33Y,4292
4
4
  field_audit/const.py,sha256=U7c5Y4a5YjmvaZjsUpDhdTOvkcoaS5aqFf_L3C5GtYk,1135
5
- field_audit/field_audit.py,sha256=qPHke6cpeWre6jFodiWsDIkyxKwCStDBtunQjUIwCZ8,5891
5
+ field_audit/field_audit.py,sha256=dy3TMOeKJiNGy4XainDN2wvx-FJzwq_YgDTH8aLVOnY,8698
6
6
  field_audit/middleware.py,sha256=JQMdM1vITddHFCqIX_M8_UDIvbCKU8BhY_sWccGVS-Y,468
7
- field_audit/models.py,sha256=0MaZa3M2he6yrP-lQ09atCwSWFaPT-F_6RpzobQnUA0,29728
7
+ field_audit/models.py,sha256=8EeWtA6lC7xtm-G8Dx44nN-nHcxAxJbMQOd5kjr_EAQ,31663
8
8
  field_audit/utils.py,sha256=tILEuWG8JCP98vWsZckdpcjcNi5BlBDbBGDma_WVIlc,3437
9
9
  field_audit/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  field_audit/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  field_audit/management/commands/bootstrap_field_audit_events.py,sha256=QN8PNKqTM-_8qHQX10xa6RjxvZMvoLkhNb_PfTYfr-k,4263
12
12
  field_audit/migrations/0001_initial.py,sha256=UXnmSkG8ZZmh-4DrqYACH9LSJ0Mh74nW0u_yKdS1ctg,1297
13
13
  field_audit/migrations/0002_add_is_bootstrap_column.py,sha256=lkPNMk9Kb6IeaX9E08Snar-0_zr1epVrW9VSq24f9Ns,972
14
+ field_audit/migrations/0003_alter_auditevent_change_context_and_more.py,sha256=0MxLTzKAGCsIc6674-RZc6wGImbHpy5D069fTPbCjak,933
14
15
  field_audit/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- django_field_audit-1.2.9.dist-info/LICENSE,sha256=DUmNjtND8byIKaTpjoksSUVazUJ_-3sPWOyyLiOvyDs,1508
16
- django_field_audit-1.2.9.dist-info/METADATA,sha256=K2B20mIRsOpglILDX4jEcjOn2muUuWZ3sFsMBI0ZiLE,10564
17
- django_field_audit-1.2.9.dist-info/WHEEL,sha256=9Hm2OB-j1QcCUq9Jguht7ayGIIZBRTdOXD1qg9cCgPM,109
18
- django_field_audit-1.2.9.dist-info/top_level.txt,sha256=wa_olzk0PU62yd5m6tScO-flaKF_xQyLIV_hZY0vi38,12
19
- django_field_audit-1.2.9.dist-info/RECORD,,
16
+ django_field_audit-1.3.0.dist-info/LICENSE,sha256=DUmNjtND8byIKaTpjoksSUVazUJ_-3sPWOyyLiOvyDs,1508
17
+ django_field_audit-1.3.0.dist-info/METADATA,sha256=VsvochcqmC3JBnJ-5BuWhtDSY2X2ky1Pta2pGg6Ng8c,11952
18
+ django_field_audit-1.3.0.dist-info/WHEEL,sha256=9Hm2OB-j1QcCUq9Jguht7ayGIIZBRTdOXD1qg9cCgPM,109
19
+ django_field_audit-1.3.0.dist-info/top_level.txt,sha256=wa_olzk0PU62yd5m6tScO-flaKF_xQyLIV_hZY0vi38,12
20
+ django_field_audit-1.3.0.dist-info/RECORD,,
field_audit/__init__.py CHANGED
@@ -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
+ ]
field_audit/models.py CHANGED
@@ -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,