django-field-audit 1.2.9__tar.gz → 1.4.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.egg-info → django_field_audit-1.4.0}/PKG-INFO +95 -39
- django_field_audit-1.2.9/PKG-INFO → django_field_audit-1.4.0/README.md +85 -55
- django_field_audit-1.4.0/field_audit/__init__.py +4 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/field_audit.py +97 -8
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/management/commands/bootstrap_field_audit_events.py +7 -4
- django_field_audit-1.4.0/field_audit/migrations/0003_alter_auditevent_change_context_and_more.py +32 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/models.py +238 -149
- django_field_audit-1.4.0/field_audit/services.py +378 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/utils.py +5 -2
- django_field_audit-1.4.0/pyproject.toml +71 -0
- django_field_audit-1.2.9/README.md +0 -269
- django_field_audit-1.2.9/django_field_audit.egg-info/SOURCES.txt +0 -30
- django_field_audit-1.2.9/django_field_audit.egg-info/dependency_links.txt +0 -1
- django_field_audit-1.2.9/django_field_audit.egg-info/top_level.txt +0 -1
- django_field_audit-1.2.9/field_audit/__init__.py +0 -3
- django_field_audit-1.2.9/setup.cfg +0 -16
- django_field_audit-1.2.9/setup.py +0 -53
- django_field_audit-1.2.9/tests/test_apps.py +0 -18
- django_field_audit-1.2.9/tests/test_auditors.py +0 -238
- django_field_audit-1.2.9/tests/test_bootstrap_field_audit_events.py +0 -144
- django_field_audit-1.2.9/tests/test_django_compat.py +0 -148
- django_field_audit-1.2.9/tests/test_field_audit.py +0 -178
- django_field_audit-1.2.9/tests/test_middleware.py +0 -25
- django_field_audit-1.2.9/tests/test_models.py +0 -1424
- django_field_audit-1.2.9/tests/test_utils.py +0 -83
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/LICENSE +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/apps.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/auditors.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/const.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/management/__init__.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/management/commands/__init__.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/middleware.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/migrations/0001_initial.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
- {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/migrations/__init__.py +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: django-field-audit
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Audit Field Changes on Django Models
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Classifier: Development Status :: 3 - Alpha
|
|
5
|
+
Author-email: Joel Miller <jmiller@dimagi.com>, Simon Kelly <simongdkelly@gmail.com>, Graham Herceg <gherceg@dimagi.com>, Chris Smit <chris.smit@dimagi.com>, Daniel Miller <millerdev@gmail.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
9
|
Classifier: Environment :: Web Environment
|
|
11
10
|
Classifier: Framework :: Django
|
|
12
11
|
Classifier: Intended Audience :: Developers
|
|
@@ -18,22 +17,11 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
-
Classifier: Framework :: Django
|
|
22
|
-
Classifier: Framework :: Django :: 3
|
|
23
|
-
Classifier: Framework :: Django :: 3.2
|
|
24
|
-
Classifier: Framework :: Django :: 4
|
|
25
|
-
Classifier: Framework :: Django :: 4.2
|
|
26
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
-
Description-Content-Type: text/markdown
|
|
28
21
|
License-File: LICENSE
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Dynamic: home-page
|
|
33
|
-
Dynamic: license
|
|
34
|
-
Dynamic: maintainer
|
|
35
|
-
Dynamic: maintainer-email
|
|
36
|
-
Dynamic: summary
|
|
22
|
+
Requires-Dist: django>=3.2
|
|
23
|
+
Project-URL: Homepage, https://github.com/dimagi/django-field-audit
|
|
24
|
+
Project-URL: Repository, https://github.com/dimagi/django-field-audit
|
|
37
25
|
|
|
38
26
|
# Audit Field Changes on Django Models
|
|
39
27
|
|
|
@@ -102,6 +90,42 @@ FIELD_AUDIT_AUDITORS = []
|
|
|
102
90
|
|:----------------------------------|:---------------------------------------------------------------|:------------------------
|
|
103
91
|
| `FIELD_AUDIT_AUDITEVENT_MANAGER` | A custom manager to use for the `AuditEvent` Model. | `field_audit.models.DefaultAuditEventManager`
|
|
104
92
|
| `FIELD_AUDIT_AUDITORS` | A custom list of auditors for acquiring `change_context` info. | `["field_audit.auditors.RequestAuditor", "field_audit.auditors.SystemUserAuditor"]`
|
|
93
|
+
| `FIELD_AUDIT_SERVICE_CLASS` | A custom service class for audit logic implementation. | `field_audit.services.AuditService`
|
|
94
|
+
|
|
95
|
+
### Custom Audit Service
|
|
96
|
+
|
|
97
|
+
The audit logic has been extracted into a separate `AuditService` class to improve separation of concerns and enable easier customization of audit behavior. Users can provide custom audit implementations by subclassing `AuditService` and configuring the `FIELD_AUDIT_SERVICE_CLASS` setting.
|
|
98
|
+
|
|
99
|
+
#### Creating a Custom Audit Service
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# myapp/audit.py
|
|
103
|
+
|
|
104
|
+
from field_audit import AuditService
|
|
105
|
+
|
|
106
|
+
class CustomAuditService(AuditService):
|
|
107
|
+
def get_field_value(self, instance, field_name, bootstrap=False):
|
|
108
|
+
# Custom logic for extracting field values
|
|
109
|
+
value = super().get_field_value(instance, field_name, bootstrap)
|
|
110
|
+
|
|
111
|
+
# Example: custom serialization or transformation
|
|
112
|
+
if field_name == 'sensitive_field':
|
|
113
|
+
value = '[REDACTED]'
|
|
114
|
+
|
|
115
|
+
return value
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Then configure it in your Django settings:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# settings.py
|
|
122
|
+
|
|
123
|
+
FIELD_AUDIT_SERVICE_CLASS = 'myapp.audit.CustomAuditService'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Backward Compatibility
|
|
127
|
+
|
|
128
|
+
The original `AuditEvent` class methods are maintained for backward compatibility but are now deprecated in favor of the service-based approach. These methods will issue deprecation warnings and delegate to the configured audit service.
|
|
105
129
|
|
|
106
130
|
### Model Auditing
|
|
107
131
|
|
|
@@ -177,6 +201,44 @@ details:
|
|
|
177
201
|
are only committed to the database if audit events are successfully created
|
|
178
202
|
and saved as well.
|
|
179
203
|
|
|
204
|
+
### Auditing Many-to-Many fields
|
|
205
|
+
|
|
206
|
+
Many-to-Many field changes are automatically audited through Django signals when
|
|
207
|
+
included in the `@audit_fields` decorator. Changes to M2M relationships generate
|
|
208
|
+
audit events immediately without requiring `save()` calls.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
# Example model with audited M2M field
|
|
212
|
+
@audit_fields("name", "title", "certifications")
|
|
213
|
+
class CrewMember(models.Model):
|
|
214
|
+
name = models.CharField(max_length=256)
|
|
215
|
+
title = models.CharField(max_length=64)
|
|
216
|
+
certifications = models.ManyToManyField('Certification', blank=True)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### Supported M2M operations
|
|
220
|
+
|
|
221
|
+
All standard M2M operations create audit events:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
crew_member = CrewMember.objects.create(name='Test Pilot', title='Captain')
|
|
225
|
+
cert1 = Certification.objects.create(name='PPL', certification_type='Private')
|
|
226
|
+
|
|
227
|
+
crew_member.certifications.add(cert1) # Creates audit event
|
|
228
|
+
crew_member.certifications.remove(cert1) # Creates audit event
|
|
229
|
+
crew_member.certifications.set([cert1]) # Creates audit event
|
|
230
|
+
crew_member.certifications.clear() # Creates audit event
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
#### M2M audit event structure
|
|
234
|
+
|
|
235
|
+
M2M changes use specific delta structures in audit events:
|
|
236
|
+
|
|
237
|
+
- **Add**: `{'certifications': {'add': [1, 2]}}`
|
|
238
|
+
- **Remove**: `{'certifications': {'remove': [2]}}`
|
|
239
|
+
- **Clear**: `{'certifications': {'remove': [1, 2]}}`
|
|
240
|
+
- **Create** / **Bootstrap**: `{'certifications': {'new': []}}`
|
|
241
|
+
|
|
180
242
|
#### Bootstrap events for models with existing records
|
|
181
243
|
|
|
182
244
|
In the scenario where auditing is enabled for a model with existing data, it can
|
|
@@ -242,12 +304,11 @@ All feature and bug contributions are expected to be covered by tests.
|
|
|
242
304
|
|
|
243
305
|
### Setup for developers
|
|
244
306
|
|
|
245
|
-
|
|
307
|
+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management. Install uv and then install the project dependencies:
|
|
246
308
|
|
|
247
309
|
```shell
|
|
248
310
|
cd django-field-audit
|
|
249
|
-
|
|
250
|
-
pip install django pynose flake8 coverage
|
|
311
|
+
uv sync
|
|
251
312
|
```
|
|
252
313
|
|
|
253
314
|
### Running tests
|
|
@@ -258,18 +319,18 @@ your local Python's `sqlite3` library ships with the `JSON1` extension enabled
|
|
|
258
319
|
|
|
259
320
|
- Tests
|
|
260
321
|
```shell
|
|
261
|
-
|
|
322
|
+
uv run pytest
|
|
262
323
|
```
|
|
263
324
|
|
|
264
325
|
- Style check
|
|
265
326
|
```shell
|
|
266
|
-
|
|
327
|
+
ruff check
|
|
267
328
|
```
|
|
268
329
|
|
|
269
330
|
- Coverage
|
|
270
331
|
```shell
|
|
271
|
-
coverage run -m
|
|
272
|
-
coverage report -m
|
|
332
|
+
uv run coverage run -m pytest
|
|
333
|
+
uv run coverage report -m
|
|
273
334
|
```
|
|
274
335
|
|
|
275
336
|
### Adding migrations
|
|
@@ -277,30 +338,25 @@ your local Python's `sqlite3` library ships with the `JSON1` extension enabled
|
|
|
277
338
|
The example `manage.py` is available for making new migrations.
|
|
278
339
|
|
|
279
340
|
```shell
|
|
280
|
-
python example/manage.py makemigrations field_audit
|
|
341
|
+
uv run python example/manage.py makemigrations field_audit
|
|
281
342
|
```
|
|
282
343
|
|
|
283
|
-
###
|
|
344
|
+
### Publishing a new version to PyPI
|
|
284
345
|
|
|
285
|
-
|
|
286
|
-
|
|
346
|
+
Push a new tag to Github using the format vX.Y.Z where X.Y.Z matches the version
|
|
347
|
+
in [`__init__.py`](field_audit/__init__.py). Also ensure that the changelog is up to date.
|
|
287
348
|
|
|
288
|
-
|
|
289
|
-
pip install -r pkg-requires.txt
|
|
290
|
-
|
|
291
|
-
python setup.py sdist bdist_wheel
|
|
292
|
-
twine upload dist/*
|
|
293
|
-
```
|
|
349
|
+
Publishing is automated with [Github Actions](.github/workflows/pypi.yml).
|
|
294
350
|
|
|
295
351
|
## TODO
|
|
296
352
|
|
|
297
353
|
- Implement auditing for the remaining "special" QuerySet write operations:
|
|
298
354
|
- `bulk_update()`
|
|
299
355
|
- Write full library documentation using github.io.
|
|
300
|
-
- Switch to `pytest` to support Python 3.10.
|
|
301
356
|
|
|
302
357
|
### Backlog
|
|
303
358
|
|
|
304
359
|
- Add to optimization for `instance.save(save_fields=[...])` [maybe].
|
|
305
360
|
- Support adding new audit fields on the same model at different times (instead
|
|
306
361
|
of raising `AlreadyAudited`) [maybe].
|
|
362
|
+
|
|
@@ -1,40 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.2
|
|
2
|
-
Name: django-field-audit
|
|
3
|
-
Version: 1.2.9
|
|
4
|
-
Summary: Audit Field Changes on Django Models
|
|
5
|
-
Home-page: https://github.com/dimagi/django-field-audit
|
|
6
|
-
Maintainer: Joel Miller
|
|
7
|
-
Maintainer-email: jmiller@dimagi.com
|
|
8
|
-
License: BSD License
|
|
9
|
-
Classifier: Development Status :: 3 - Alpha
|
|
10
|
-
Classifier: Environment :: Web Environment
|
|
11
|
-
Classifier: Framework :: Django
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: BSD License
|
|
14
|
-
Classifier: Operating System :: OS Independent
|
|
15
|
-
Classifier: Programming Language :: Python
|
|
16
|
-
Classifier: Programming Language :: Python :: 3
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
-
Classifier: Framework :: Django
|
|
22
|
-
Classifier: Framework :: Django :: 3
|
|
23
|
-
Classifier: Framework :: Django :: 3.2
|
|
24
|
-
Classifier: Framework :: Django :: 4
|
|
25
|
-
Classifier: Framework :: Django :: 4.2
|
|
26
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
-
Description-Content-Type: text/markdown
|
|
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
|
|
37
|
-
|
|
38
1
|
# Audit Field Changes on Django Models
|
|
39
2
|
|
|
40
3
|
[![tests][tests_badge]][tests_link]
|
|
@@ -102,6 +65,42 @@ FIELD_AUDIT_AUDITORS = []
|
|
|
102
65
|
|:----------------------------------|:---------------------------------------------------------------|:------------------------
|
|
103
66
|
| `FIELD_AUDIT_AUDITEVENT_MANAGER` | A custom manager to use for the `AuditEvent` Model. | `field_audit.models.DefaultAuditEventManager`
|
|
104
67
|
| `FIELD_AUDIT_AUDITORS` | A custom list of auditors for acquiring `change_context` info. | `["field_audit.auditors.RequestAuditor", "field_audit.auditors.SystemUserAuditor"]`
|
|
68
|
+
| `FIELD_AUDIT_SERVICE_CLASS` | A custom service class for audit logic implementation. | `field_audit.services.AuditService`
|
|
69
|
+
|
|
70
|
+
### Custom Audit Service
|
|
71
|
+
|
|
72
|
+
The audit logic has been extracted into a separate `AuditService` class to improve separation of concerns and enable easier customization of audit behavior. Users can provide custom audit implementations by subclassing `AuditService` and configuring the `FIELD_AUDIT_SERVICE_CLASS` setting.
|
|
73
|
+
|
|
74
|
+
#### Creating a Custom Audit Service
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# myapp/audit.py
|
|
78
|
+
|
|
79
|
+
from field_audit import AuditService
|
|
80
|
+
|
|
81
|
+
class CustomAuditService(AuditService):
|
|
82
|
+
def get_field_value(self, instance, field_name, bootstrap=False):
|
|
83
|
+
# Custom logic for extracting field values
|
|
84
|
+
value = super().get_field_value(instance, field_name, bootstrap)
|
|
85
|
+
|
|
86
|
+
# Example: custom serialization or transformation
|
|
87
|
+
if field_name == 'sensitive_field':
|
|
88
|
+
value = '[REDACTED]'
|
|
89
|
+
|
|
90
|
+
return value
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Then configure it in your Django settings:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# settings.py
|
|
97
|
+
|
|
98
|
+
FIELD_AUDIT_SERVICE_CLASS = 'myapp.audit.CustomAuditService'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
#### Backward Compatibility
|
|
102
|
+
|
|
103
|
+
The original `AuditEvent` class methods are maintained for backward compatibility but are now deprecated in favor of the service-based approach. These methods will issue deprecation warnings and delegate to the configured audit service.
|
|
105
104
|
|
|
106
105
|
### Model Auditing
|
|
107
106
|
|
|
@@ -177,6 +176,44 @@ details:
|
|
|
177
176
|
are only committed to the database if audit events are successfully created
|
|
178
177
|
and saved as well.
|
|
179
178
|
|
|
179
|
+
### Auditing Many-to-Many fields
|
|
180
|
+
|
|
181
|
+
Many-to-Many field changes are automatically audited through Django signals when
|
|
182
|
+
included in the `@audit_fields` decorator. Changes to M2M relationships generate
|
|
183
|
+
audit events immediately without requiring `save()` calls.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
# Example model with audited M2M field
|
|
187
|
+
@audit_fields("name", "title", "certifications")
|
|
188
|
+
class CrewMember(models.Model):
|
|
189
|
+
name = models.CharField(max_length=256)
|
|
190
|
+
title = models.CharField(max_length=64)
|
|
191
|
+
certifications = models.ManyToManyField('Certification', blank=True)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### Supported M2M operations
|
|
195
|
+
|
|
196
|
+
All standard M2M operations create audit events:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
crew_member = CrewMember.objects.create(name='Test Pilot', title='Captain')
|
|
200
|
+
cert1 = Certification.objects.create(name='PPL', certification_type='Private')
|
|
201
|
+
|
|
202
|
+
crew_member.certifications.add(cert1) # Creates audit event
|
|
203
|
+
crew_member.certifications.remove(cert1) # Creates audit event
|
|
204
|
+
crew_member.certifications.set([cert1]) # Creates audit event
|
|
205
|
+
crew_member.certifications.clear() # Creates audit event
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### M2M audit event structure
|
|
209
|
+
|
|
210
|
+
M2M changes use specific delta structures in audit events:
|
|
211
|
+
|
|
212
|
+
- **Add**: `{'certifications': {'add': [1, 2]}}`
|
|
213
|
+
- **Remove**: `{'certifications': {'remove': [2]}}`
|
|
214
|
+
- **Clear**: `{'certifications': {'remove': [1, 2]}}`
|
|
215
|
+
- **Create** / **Bootstrap**: `{'certifications': {'new': []}}`
|
|
216
|
+
|
|
180
217
|
#### Bootstrap events for models with existing records
|
|
181
218
|
|
|
182
219
|
In the scenario where auditing is enabled for a model with existing data, it can
|
|
@@ -242,12 +279,11 @@ All feature and bug contributions are expected to be covered by tests.
|
|
|
242
279
|
|
|
243
280
|
### Setup for developers
|
|
244
281
|
|
|
245
|
-
|
|
282
|
+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management. Install uv and then install the project dependencies:
|
|
246
283
|
|
|
247
284
|
```shell
|
|
248
285
|
cd django-field-audit
|
|
249
|
-
|
|
250
|
-
pip install django pynose flake8 coverage
|
|
286
|
+
uv sync
|
|
251
287
|
```
|
|
252
288
|
|
|
253
289
|
### Running tests
|
|
@@ -258,18 +294,18 @@ your local Python's `sqlite3` library ships with the `JSON1` extension enabled
|
|
|
258
294
|
|
|
259
295
|
- Tests
|
|
260
296
|
```shell
|
|
261
|
-
|
|
297
|
+
uv run pytest
|
|
262
298
|
```
|
|
263
299
|
|
|
264
300
|
- Style check
|
|
265
301
|
```shell
|
|
266
|
-
|
|
302
|
+
ruff check
|
|
267
303
|
```
|
|
268
304
|
|
|
269
305
|
- Coverage
|
|
270
306
|
```shell
|
|
271
|
-
coverage run -m
|
|
272
|
-
coverage report -m
|
|
307
|
+
uv run coverage run -m pytest
|
|
308
|
+
uv run coverage report -m
|
|
273
309
|
```
|
|
274
310
|
|
|
275
311
|
### Adding migrations
|
|
@@ -277,27 +313,21 @@ your local Python's `sqlite3` library ships with the `JSON1` extension enabled
|
|
|
277
313
|
The example `manage.py` is available for making new migrations.
|
|
278
314
|
|
|
279
315
|
```shell
|
|
280
|
-
python example/manage.py makemigrations field_audit
|
|
316
|
+
uv run python example/manage.py makemigrations field_audit
|
|
281
317
|
```
|
|
282
318
|
|
|
283
|
-
###
|
|
319
|
+
### Publishing a new version to PyPI
|
|
284
320
|
|
|
285
|
-
|
|
286
|
-
|
|
321
|
+
Push a new tag to Github using the format vX.Y.Z where X.Y.Z matches the version
|
|
322
|
+
in [`__init__.py`](field_audit/__init__.py). Also ensure that the changelog is up to date.
|
|
287
323
|
|
|
288
|
-
|
|
289
|
-
pip install -r pkg-requires.txt
|
|
290
|
-
|
|
291
|
-
python setup.py sdist bdist_wheel
|
|
292
|
-
twine upload dist/*
|
|
293
|
-
```
|
|
324
|
+
Publishing is automated with [Github Actions](.github/workflows/pypi.yml).
|
|
294
325
|
|
|
295
326
|
## TODO
|
|
296
327
|
|
|
297
328
|
- Implement auditing for the remaining "special" QuerySet write operations:
|
|
298
329
|
- `bulk_update()`
|
|
299
330
|
- Write full library documentation using github.io.
|
|
300
|
-
- Switch to `pytest` to support Python 3.10.
|
|
301
331
|
|
|
302
332
|
### Backlog
|
|
303
333
|
|
|
@@ -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
|
|
|
@@ -56,18 +57,21 @@ def audit_fields(*field_names, class_path=None, audit_special_queryset_writes=Fa
|
|
|
56
57
|
raise AlreadyAudited(cls)
|
|
57
58
|
if not issubclass(cls, models.Model):
|
|
58
59
|
raise ValueError(f"expected Model subclass, got: {cls}")
|
|
59
|
-
|
|
60
|
+
service.attach_field_names(cls, field_names)
|
|
60
61
|
if audit_special_queryset_writes:
|
|
61
62
|
_verify_auditing_manager(cls)
|
|
62
63
|
cls.__init__ = _decorate_init(cls.__init__)
|
|
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:
|
|
69
72
|
raise ValueError("at least one field name is required")
|
|
70
|
-
from .
|
|
73
|
+
from .services import get_audit_service
|
|
74
|
+
service = get_audit_service()
|
|
71
75
|
return wrapper
|
|
72
76
|
|
|
73
77
|
|
|
@@ -98,8 +102,9 @@ def _decorate_init(init):
|
|
|
98
102
|
@wraps(init)
|
|
99
103
|
def wrapper(self, *args, **kw):
|
|
100
104
|
init(self, *args, **kw)
|
|
101
|
-
|
|
102
|
-
from .
|
|
105
|
+
service.attach_initial_values(self)
|
|
106
|
+
from .services import get_audit_service
|
|
107
|
+
service = get_audit_service()
|
|
103
108
|
return wrapper
|
|
104
109
|
|
|
105
110
|
|
|
@@ -121,7 +126,7 @@ def _decorate_db_write(func):
|
|
|
121
126
|
db = router.db_for_write(type(self))
|
|
122
127
|
with transaction.atomic(using=db):
|
|
123
128
|
ret = func(self, *args, **kw)
|
|
124
|
-
|
|
129
|
+
service.audit_field_changes(
|
|
125
130
|
self,
|
|
126
131
|
is_create,
|
|
127
132
|
is_delete,
|
|
@@ -133,7 +138,8 @@ def _decorate_db_write(func):
|
|
|
133
138
|
is_delete = func.__name__ == "delete"
|
|
134
139
|
if not is_save and not is_delete:
|
|
135
140
|
raise ValueError(f"invalid function for decoration: {func}")
|
|
136
|
-
from .
|
|
141
|
+
from .services import get_audit_service
|
|
142
|
+
service = get_audit_service()
|
|
137
143
|
return wrapper
|
|
138
144
|
|
|
139
145
|
|
|
@@ -147,13 +153,96 @@ def _decorate_refresh_from_db(func):
|
|
|
147
153
|
@wraps(func)
|
|
148
154
|
def wrapper(self, using=None, fields=None, **kwargs):
|
|
149
155
|
if fields is not None:
|
|
150
|
-
fields = set(fields) | set(
|
|
156
|
+
fields = set(fields) | set(service.get_field_names(self))
|
|
151
157
|
func(self, using, fields, **kwargs)
|
|
152
158
|
|
|
153
|
-
from .
|
|
159
|
+
from .services import get_audit_service
|
|
160
|
+
service = get_audit_service()
|
|
154
161
|
return wrapper
|
|
155
162
|
|
|
156
163
|
|
|
164
|
+
def _register_m2m_signals(cls, field_names):
|
|
165
|
+
"""Register m2m_changed signal handlers for ManyToManyFields.
|
|
166
|
+
|
|
167
|
+
:param cls: The model class being audited
|
|
168
|
+
:param field_names: List of field names that are being audited
|
|
169
|
+
"""
|
|
170
|
+
for field_name in field_names:
|
|
171
|
+
try:
|
|
172
|
+
field = cls._meta.get_field(field_name)
|
|
173
|
+
if isinstance(field, models.ManyToManyField):
|
|
174
|
+
m2m_changed.connect(
|
|
175
|
+
_m2m_changed_handler,
|
|
176
|
+
sender=field.remote_field.through,
|
|
177
|
+
weak=False
|
|
178
|
+
)
|
|
179
|
+
except Exception:
|
|
180
|
+
# If field doesn't exist or isn't a M2M field, continue
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _m2m_changed_handler(sender, instance, action, pk_set, **kwargs):
|
|
185
|
+
"""Signal handler for m2m_changed to audit ManyToManyField changes.
|
|
186
|
+
|
|
187
|
+
:param sender: The intermediate model class for the ManyToManyField
|
|
188
|
+
:param instance: The instance whose many-to-many relation is updated
|
|
189
|
+
:param action: A string indicating the type of update
|
|
190
|
+
:param pk_set: For add/remove actions, set of primary key values
|
|
191
|
+
"""
|
|
192
|
+
from .services import get_audit_service
|
|
193
|
+
|
|
194
|
+
service = get_audit_service()
|
|
195
|
+
|
|
196
|
+
if action not in ('post_add', 'post_remove', 'post_clear', 'pre_clear'):
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if type(instance) not in _audited_models:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# Find which M2M field this change relates to
|
|
203
|
+
m2m_field = None
|
|
204
|
+
field_name = None
|
|
205
|
+
for field in instance._meta.get_fields():
|
|
206
|
+
if (
|
|
207
|
+
isinstance(field, models.ManyToManyField) and
|
|
208
|
+
hasattr(field, 'remote_field') and
|
|
209
|
+
field.remote_field.through == sender
|
|
210
|
+
):
|
|
211
|
+
m2m_field = field
|
|
212
|
+
field_name = field.name
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
if not m2m_field or field_name not in service.get_field_names(instance):
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if action == 'pre_clear':
|
|
219
|
+
# `pk_set` not supplied for clear actions. Determine initial values
|
|
220
|
+
# in the `pre_clear` event
|
|
221
|
+
service.attach_initial_m2m_values(instance, field_name)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if action == 'post_clear':
|
|
225
|
+
initial_values = service.get_initial_m2m_values(instance, field_name)
|
|
226
|
+
if not initial_values:
|
|
227
|
+
return
|
|
228
|
+
delta = {field_name: {'remove': initial_values}}
|
|
229
|
+
else:
|
|
230
|
+
if not pk_set:
|
|
231
|
+
# the change was a no-op
|
|
232
|
+
return
|
|
233
|
+
delta_key = 'add' if action == 'post_add' else 'remove'
|
|
234
|
+
delta = {field_name: {delta_key: list(pk_set)}}
|
|
235
|
+
|
|
236
|
+
req = request.get()
|
|
237
|
+
event = service.create_audit_event(
|
|
238
|
+
instance.pk, instance.__class__, delta, False, False, req
|
|
239
|
+
)
|
|
240
|
+
if event is not None:
|
|
241
|
+
event.save()
|
|
242
|
+
|
|
243
|
+
service.clear_initial_m2m_field_values(instance, field_name)
|
|
244
|
+
|
|
245
|
+
|
|
157
246
|
def get_audited_models():
|
|
158
247
|
return _audited_models.copy()
|
|
159
248
|
|
|
@@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand, CommandError
|
|
|
4
4
|
|
|
5
5
|
from field_audit.const import BOOTSTRAP_BATCH_SIZE
|
|
6
6
|
from field_audit.field_audit import get_audited_models
|
|
7
|
-
from field_audit.
|
|
7
|
+
from field_audit.services import get_audit_service
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class Command(BaseCommand):
|
|
@@ -64,6 +64,9 @@ class Command(BaseCommand):
|
|
|
64
64
|
if batch_size == 0:
|
|
65
65
|
batch_size = None
|
|
66
66
|
self.batch_size = batch_size
|
|
67
|
+
|
|
68
|
+
self.service = get_audit_service()
|
|
69
|
+
|
|
67
70
|
for name in models:
|
|
68
71
|
model_class = self.models[name]
|
|
69
72
|
self.operations[operation](self, model_class)
|
|
@@ -74,7 +77,7 @@ class Command(BaseCommand):
|
|
|
74
77
|
with self.bootstrap_action_log(log_head) as stream:
|
|
75
78
|
count = self.do_bootstrap(
|
|
76
79
|
model_class,
|
|
77
|
-
|
|
80
|
+
self.service.bootstrap_existing_model_records,
|
|
78
81
|
iter_records=query.iterator,
|
|
79
82
|
)
|
|
80
83
|
stream.write(f"done ({count})")
|
|
@@ -82,11 +85,11 @@ class Command(BaseCommand):
|
|
|
82
85
|
def top_up_missing(self, model_class):
|
|
83
86
|
log_head = f"top-up: {model_class} ... "
|
|
84
87
|
with self.bootstrap_action_log(log_head) as stream:
|
|
85
|
-
count = self.do_bootstrap(model_class,
|
|
88
|
+
count = self.do_bootstrap(model_class, self.service.bootstrap_top_up)
|
|
86
89
|
stream.write(f"done ({count})")
|
|
87
90
|
|
|
88
91
|
def do_bootstrap(self, model_class, bootstrap_method, **bootstrap_kw):
|
|
89
|
-
field_names =
|
|
92
|
+
field_names = self.service.get_field_names(model_class)
|
|
90
93
|
if not field_names:
|
|
91
94
|
raise CommandError(
|
|
92
95
|
f"invalid fields ({field_names!r}) for model: {model_class}"
|
django_field_audit-1.4.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
|
+
]
|