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.

Files changed (35) hide show
  1. {django_field_audit-1.2.9/django_field_audit.egg-info → django_field_audit-1.4.0}/PKG-INFO +95 -39
  2. django_field_audit-1.2.9/PKG-INFO → django_field_audit-1.4.0/README.md +85 -55
  3. django_field_audit-1.4.0/field_audit/__init__.py +4 -0
  4. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/field_audit.py +97 -8
  5. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/management/commands/bootstrap_field_audit_events.py +7 -4
  6. django_field_audit-1.4.0/field_audit/migrations/0003_alter_auditevent_change_context_and_more.py +32 -0
  7. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/models.py +238 -149
  8. django_field_audit-1.4.0/field_audit/services.py +378 -0
  9. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/utils.py +5 -2
  10. django_field_audit-1.4.0/pyproject.toml +71 -0
  11. django_field_audit-1.2.9/README.md +0 -269
  12. django_field_audit-1.2.9/django_field_audit.egg-info/SOURCES.txt +0 -30
  13. django_field_audit-1.2.9/django_field_audit.egg-info/dependency_links.txt +0 -1
  14. django_field_audit-1.2.9/django_field_audit.egg-info/top_level.txt +0 -1
  15. django_field_audit-1.2.9/field_audit/__init__.py +0 -3
  16. django_field_audit-1.2.9/setup.cfg +0 -16
  17. django_field_audit-1.2.9/setup.py +0 -53
  18. django_field_audit-1.2.9/tests/test_apps.py +0 -18
  19. django_field_audit-1.2.9/tests/test_auditors.py +0 -238
  20. django_field_audit-1.2.9/tests/test_bootstrap_field_audit_events.py +0 -144
  21. django_field_audit-1.2.9/tests/test_django_compat.py +0 -148
  22. django_field_audit-1.2.9/tests/test_field_audit.py +0 -178
  23. django_field_audit-1.2.9/tests/test_middleware.py +0 -25
  24. django_field_audit-1.2.9/tests/test_models.py +0 -1424
  25. django_field_audit-1.2.9/tests/test_utils.py +0 -83
  26. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/LICENSE +0 -0
  27. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/apps.py +0 -0
  28. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/auditors.py +0 -0
  29. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/const.py +0 -0
  30. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/management/__init__.py +0 -0
  31. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/management/commands/__init__.py +0 -0
  32. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/middleware.py +0 -0
  33. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/migrations/0001_initial.py +0 -0
  34. {django_field_audit-1.2.9 → django_field_audit-1.4.0}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
  35. {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.2
1
+ Metadata-Version: 2.4
2
2
  Name: django-field-audit
3
- Version: 1.2.9
3
+ Version: 1.4.0
4
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
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
- 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
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
- Create/activate a python virtualenv and install the required dependencies.
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
- mkvirtualenv django-field-audit # or however you choose to setup your environment
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
- nosetests
322
+ uv run pytest
262
323
  ```
263
324
 
264
325
  - Style check
265
326
  ```shell
266
- flake8 --config=setup.cfg
327
+ ruff check
267
328
  ```
268
329
 
269
330
  - Coverage
270
331
  ```shell
271
- coverage run -m nose
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
- ### Uploading to PyPI
344
+ ### Publishing a new version to PyPI
284
345
 
285
- First bump the package version in the `field_audit/__init__.py` file. Then create a changelog entry in the CHANGELOG.md
286
- file. After these changes are merged, you should tag the main branch with the new version. Then, package and upload the generated files to PyPI.
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
- ```shell
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
- Create/activate a python virtualenv and install the required dependencies.
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
- mkvirtualenv django-field-audit # or however you choose to setup your environment
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
- nosetests
297
+ uv run pytest
262
298
  ```
263
299
 
264
300
  - Style check
265
301
  ```shell
266
- flake8 --config=setup.cfg
302
+ ruff check
267
303
  ```
268
304
 
269
305
  - Coverage
270
306
  ```shell
271
- coverage run -m nose
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
- ### Uploading to PyPI
319
+ ### Publishing a new version to PyPI
284
320
 
285
- First bump the package version in the `field_audit/__init__.py` file. Then create a changelog entry in the CHANGELOG.md
286
- file. After these changes are merged, you should tag the main branch with the new version. Then, package and upload the generated files to PyPI.
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
- ```shell
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
 
@@ -0,0 +1,4 @@
1
+ from .field_audit import audit_fields # noqa: F401
2
+ from .services import AuditService, get_audit_service # noqa: F401
3
+
4
+ __version__ = "1.4.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
 
@@ -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
- AuditEvent.attach_field_names(cls, field_names)
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 .models import AuditEvent
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
- AuditEvent.attach_initial_values(self)
102
- from .models import AuditEvent
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
- AuditEvent.audit_field_changes(
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 .models import AuditEvent
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(AuditEvent.field_names(self))
156
+ fields = set(fields) | set(service.get_field_names(self))
151
157
  func(self, using, fields, **kwargs)
152
158
 
153
- from .models import AuditEvent
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.models import AuditEvent
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
- AuditEvent.bootstrap_existing_model_records,
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, AuditEvent.bootstrap_top_up)
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 = AuditEvent.field_names(model_class)
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}"
@@ -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
+ ]