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.
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/PKG-INFO +39 -1
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/README.md +38 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/PKG-INFO +39 -1
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/SOURCES.txt +2 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/__init__.py +1 -1
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/field_audit.py +83 -0
- django_field_audit-1.3.0/field_audit/migrations/0003_alter_auditevent_change_context_and_more.py +32 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/models.py +53 -6
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_field_audit.py +2 -0
- django_field_audit-1.3.0/tests/test_m2m.py +229 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_models.py +27 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/LICENSE +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/dependency_links.txt +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/top_level.txt +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/apps.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/auditors.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/const.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/management/__init__.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/management/commands/__init__.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/management/commands/bootstrap_field_audit_events.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/middleware.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/migrations/0001_initial.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/migrations/__init__.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/utils.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/setup.cfg +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/setup.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_apps.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_auditors.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_bootstrap_field_audit_events.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_django_compat.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_middleware.py +0 -0
- {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.
|
|
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.
|
|
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
|
{django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
@@ -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
|
|
django_field_audit-1.3.0/field_audit/migrations/0003_alter_auditevent_change_context_and_more.py
ADDED
|
@@ -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(
|
|
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(
|
|
File without changes
|
|
File without changes
|
{django_field_audit-1.2.9 → django_field_audit-1.3.0}/django_field_audit.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_field_audit-1.2.9 → django_field_audit-1.3.0}/field_audit/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_field_audit-1.2.9 → django_field_audit-1.3.0}/tests/test_bootstrap_field_audit_events.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|