django-field-audit 1.2.8__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.
- {django_field_audit-1.2.8.dist-info → django_field_audit-1.3.0.dist-info}/METADATA +48 -2
- {django_field_audit-1.2.8.dist-info → django_field_audit-1.3.0.dist-info}/RECORD +10 -9
- {django_field_audit-1.2.8.dist-info → django_field_audit-1.3.0.dist-info}/WHEEL +1 -1
- field_audit/__init__.py +1 -1
- field_audit/auditors.py +1 -1
- field_audit/field_audit.py +83 -0
- field_audit/migrations/0003_alter_auditevent_change_context_and_more.py +32 -0
- field_audit/models.py +53 -6
- {django_field_audit-1.2.8.dist-info → django_field_audit-1.3.0.dist-info}/LICENSE +0 -0
- {django_field_audit-1.2.8.dist-info → django_field_audit-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: django-field-audit
|
|
3
|
-
Version: 1.
|
|
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
|
|
@@ -26,6 +26,14 @@ Classifier: Framework :: Django :: 4.2
|
|
|
26
26
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
|
+
Dynamic: classifier
|
|
30
|
+
Dynamic: description
|
|
31
|
+
Dynamic: description-content-type
|
|
32
|
+
Dynamic: home-page
|
|
33
|
+
Dynamic: license
|
|
34
|
+
Dynamic: maintainer
|
|
35
|
+
Dynamic: maintainer-email
|
|
36
|
+
Dynamic: summary
|
|
29
37
|
|
|
30
38
|
# Audit Field Changes on Django Models
|
|
31
39
|
|
|
@@ -169,6 +177,44 @@ details:
|
|
|
169
177
|
are only committed to the database if audit events are successfully created
|
|
170
178
|
and saved as well.
|
|
171
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
|
+
|
|
172
218
|
#### Bootstrap events for models with existing records
|
|
173
219
|
|
|
174
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=
|
|
1
|
+
field_audit/__init__.py,sha256=nqzFkr4OZWLY-xTZ5-uZhnh-Rp6iEV2tP0jPeExcq0s,75
|
|
2
2
|
field_audit/apps.py,sha256=04NYTi54zEuYPAhxEvsS61hmoPMIMVfPQKi4DOa9nE0,265
|
|
3
|
-
field_audit/auditors.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
16
|
-
django_field_audit-1.
|
|
17
|
-
django_field_audit-1.
|
|
18
|
-
django_field_audit-1.
|
|
19
|
-
django_field_audit-1.
|
|
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
field_audit/auditors.py
CHANGED
|
@@ -107,7 +107,7 @@ class SystemUserAuditor(BaseAuditor):
|
|
|
107
107
|
try:
|
|
108
108
|
# get owner of STDIN file on login sessions (e.g. SSH)
|
|
109
109
|
output = check_output(["who", "-m"], stderr=DEVNULL)
|
|
110
|
-
except CalledProcessError:
|
|
110
|
+
except (FileNotFoundError, CalledProcessError):
|
|
111
111
|
self.has_who_bin = False
|
|
112
112
|
else:
|
|
113
113
|
if output:
|
field_audit/field_audit.py
CHANGED
|
@@ -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(
|
|
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,
|
|
File without changes
|
|
File without changes
|