django-field-audit 1.2.4__tar.gz → 1.2.6__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 (23) hide show
  1. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/PKG-INFO +11 -10
  2. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/README.md +10 -9
  3. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/django_field_audit.egg-info/PKG-INFO +11 -10
  4. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/__init__.py +1 -1
  5. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/field_audit.py +13 -10
  6. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/management/commands/bootstrap_field_audit_events.py +1 -9
  7. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/models.py +185 -79
  8. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/LICENSE +0 -0
  9. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/django_field_audit.egg-info/SOURCES.txt +0 -0
  10. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/django_field_audit.egg-info/dependency_links.txt +0 -0
  11. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/django_field_audit.egg-info/top_level.txt +0 -0
  12. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/apps.py +0 -0
  13. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/auditors.py +0 -0
  14. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/const.py +0 -0
  15. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/management/__init__.py +0 -0
  16. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/management/commands/__init__.py +0 -0
  17. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/middleware.py +0 -0
  18. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/migrations/0001_initial.py +0 -0
  19. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
  20. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/migrations/__init__.py +0 -0
  21. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/field_audit/utils.py +0 -0
  22. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/setup.cfg +0 -0
  23. {django-field-audit-1.2.4 → django-field-audit-1.2.6}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-field-audit
3
- Version: 1.2.4
3
+ Version: 1.2.6
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
@@ -151,13 +151,16 @@ details:
151
151
  - Specifying `audit_special_queryset_writes=True` (step **1** above) without
152
152
  setting the default manager to an instance of `AuditingManager` (step **2**
153
153
  above) will raise an exception when the model class is evaluated.
154
- - At this time, only the `QuerySet.delete()` and `QuerySet.update()` "special"
155
- write methods can actually perform change auditing when called with
156
- `audit_action=AuditAction.AUDIT`. The other two methods are currently not
157
- implemented and will raise `NotImplementedError` if called with that action.
158
- Implementing these remaining methods remains a task for the future, see
159
- **TODO** below. All four methods do support `audit_action=AuditAction.IGNORE`
160
- usage, however.
154
+ - At this time, `QuerySet.delete()`, `QuerySet.update()`,
155
+ and `QuerySet.bulk_create()` "special" write methods can actually perform
156
+ change auditing when called with `audit_action=AuditAction.AUDIT`.
157
+ `QuerySet.bulk_update()` is not currently implemented and will raise
158
+ `NotImplementedError` if called with that action. Implementing this remaining
159
+ method remains a task for the future, see **TODO** below. All four methods do
160
+ support `audit_action=AuditAction.IGNORE` usage, however.
161
+ - All audited methods use transactions to ensure changes to audited models
162
+ are only committed to the database if audit events are successfully created
163
+ and saved as well.
161
164
 
162
165
  #### Bootstrap events for models with existing records
163
166
 
@@ -276,11 +279,9 @@ twine upload dist/*
276
279
  ## TODO
277
280
 
278
281
  - Implement auditing for the remaining "special" QuerySet write operations:
279
- - `bulk_create()`
280
282
  - `bulk_update()`
281
283
  - Write full library documentation using github.io.
282
284
  - Switch to `pytest` to support Python 3.10.
283
- - Wrap audited DB write methods in Django's transaction.atomic context manager
284
285
 
285
286
  ### Backlog
286
287
 
@@ -124,13 +124,16 @@ details:
124
124
  - Specifying `audit_special_queryset_writes=True` (step **1** above) without
125
125
  setting the default manager to an instance of `AuditingManager` (step **2**
126
126
  above) will raise an exception when the model class is evaluated.
127
- - At this time, only the `QuerySet.delete()` and `QuerySet.update()` "special"
128
- write methods can actually perform change auditing when called with
129
- `audit_action=AuditAction.AUDIT`. The other two methods are currently not
130
- implemented and will raise `NotImplementedError` if called with that action.
131
- Implementing these remaining methods remains a task for the future, see
132
- **TODO** below. All four methods do support `audit_action=AuditAction.IGNORE`
133
- usage, however.
127
+ - At this time, `QuerySet.delete()`, `QuerySet.update()`,
128
+ and `QuerySet.bulk_create()` "special" write methods can actually perform
129
+ change auditing when called with `audit_action=AuditAction.AUDIT`.
130
+ `QuerySet.bulk_update()` is not currently implemented and will raise
131
+ `NotImplementedError` if called with that action. Implementing this remaining
132
+ method remains a task for the future, see **TODO** below. All four methods do
133
+ support `audit_action=AuditAction.IGNORE` usage, however.
134
+ - All audited methods use transactions to ensure changes to audited models
135
+ are only committed to the database if audit events are successfully created
136
+ and saved as well.
134
137
 
135
138
  #### Bootstrap events for models with existing records
136
139
 
@@ -249,11 +252,9 @@ twine upload dist/*
249
252
  ## TODO
250
253
 
251
254
  - Implement auditing for the remaining "special" QuerySet write operations:
252
- - `bulk_create()`
253
255
  - `bulk_update()`
254
256
  - Write full library documentation using github.io.
255
257
  - Switch to `pytest` to support Python 3.10.
256
- - Wrap audited DB write methods in Django's transaction.atomic context manager
257
258
 
258
259
  ### Backlog
259
260
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-field-audit
3
- Version: 1.2.4
3
+ Version: 1.2.6
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
@@ -151,13 +151,16 @@ details:
151
151
  - Specifying `audit_special_queryset_writes=True` (step **1** above) without
152
152
  setting the default manager to an instance of `AuditingManager` (step **2**
153
153
  above) will raise an exception when the model class is evaluated.
154
- - At this time, only the `QuerySet.delete()` and `QuerySet.update()` "special"
155
- write methods can actually perform change auditing when called with
156
- `audit_action=AuditAction.AUDIT`. The other two methods are currently not
157
- implemented and will raise `NotImplementedError` if called with that action.
158
- Implementing these remaining methods remains a task for the future, see
159
- **TODO** below. All four methods do support `audit_action=AuditAction.IGNORE`
160
- usage, however.
154
+ - At this time, `QuerySet.delete()`, `QuerySet.update()`,
155
+ and `QuerySet.bulk_create()` "special" write methods can actually perform
156
+ change auditing when called with `audit_action=AuditAction.AUDIT`.
157
+ `QuerySet.bulk_update()` is not currently implemented and will raise
158
+ `NotImplementedError` if called with that action. Implementing this remaining
159
+ method remains a task for the future, see **TODO** below. All four methods do
160
+ support `audit_action=AuditAction.IGNORE` usage, however.
161
+ - All audited methods use transactions to ensure changes to audited models
162
+ are only committed to the database if audit events are successfully created
163
+ and saved as well.
161
164
 
162
165
  #### Bootstrap events for models with existing records
163
166
 
@@ -276,11 +279,9 @@ twine upload dist/*
276
279
  ## TODO
277
280
 
278
281
  - Implement auditing for the remaining "special" QuerySet write operations:
279
- - `bulk_create()`
280
282
  - `bulk_update()`
281
283
  - Write full library documentation using github.io.
282
284
  - Switch to `pytest` to support Python 3.10.
283
- - Wrap audited DB write methods in Django's transaction.atomic context manager
284
285
 
285
286
  ### Backlog
286
287
 
@@ -1,3 +1,3 @@
1
1
  from .field_audit import audit_fields # noqa: F401
2
2
 
3
- __version__ = "1.2.4"
3
+ __version__ = "1.2.6"
@@ -1,7 +1,7 @@
1
1
  import contextvars
2
2
  from functools import wraps
3
3
 
4
- from django.db import models
4
+ from django.db import models, router, transaction
5
5
 
6
6
  from .utils import get_fqcn
7
7
 
@@ -116,15 +116,18 @@ def _decorate_db_write(func):
116
116
  # - https://stackoverflow.com/questions/907695/
117
117
  is_create = is_save and self._state.adding
118
118
  object_pk = self.pk if is_delete else None
119
- ret = func(self, *args, **kw)
120
- AuditEvent.audit_field_changes(
121
- self,
122
- is_create,
123
- is_delete,
124
- request.get(),
125
- object_pk,
126
- )
127
- return ret
119
+
120
+ db = router.db_for_write(type(self))
121
+ with transaction.atomic(using=db):
122
+ ret = func(self, *args, **kw)
123
+ AuditEvent.audit_field_changes(
124
+ self,
125
+ is_create,
126
+ is_delete,
127
+ request.get(),
128
+ object_pk,
129
+ )
130
+ return ret
128
131
  is_save = func.__name__ == "save"
129
132
  is_delete = func.__name__ == "delete"
130
133
  if not is_save and not is_delete:
@@ -86,7 +86,7 @@ class Command(BaseCommand):
86
86
  stream.write(f"done ({count})")
87
87
 
88
88
  def do_bootstrap(self, model_class, bootstrap_method, **bootstrap_kw):
89
- field_names = self.get_field_names(model_class)
89
+ field_names = AuditEvent.field_names(model_class)
90
90
  if not field_names:
91
91
  raise CommandError(
92
92
  f"invalid fields ({field_names!r}) for model: {model_class}"
@@ -103,14 +103,6 @@ class Command(BaseCommand):
103
103
  "top-up": top_up_missing,
104
104
  }
105
105
 
106
- @staticmethod
107
- def get_field_names(model_class):
108
- """Extract the field names from a model class.
109
-
110
- TODO: expose a method on the AuditEvent class for doing this.
111
- """
112
- return AuditEvent._field_names(model_class())
113
-
114
106
  @contextmanager
115
107
  def bootstrap_action_log(self, *args, **kw):
116
108
  end = kw.pop("end", "\n")
@@ -4,7 +4,7 @@ from functools import wraps
4
4
  from itertools import islice
5
5
 
6
6
  from django.conf import settings
7
- from django.db import models, router, transaction
7
+ from django.db import models, transaction
8
8
 
9
9
  from .const import BOOTSTRAP_BATCH_SIZE
10
10
  from .utils import class_import_helper
@@ -236,12 +236,12 @@ class AuditEvent(models.Model):
236
236
  setattr(model_class, cls.ATTACH_FIELD_NAMES_AT, field_names)
237
237
 
238
238
  @classmethod
239
- def _field_names(cls, instance):
240
- """Returns the audit field names stored on the model instance's class
239
+ def field_names(cls, model_class):
240
+ """Returns the audit field names stored on the audited Model class
241
241
 
242
- :param instance: instance of a Model subclass being audited for changes
242
+ :param model_class: a Django Model class under audit
243
243
  """
244
- return getattr(instance.__class__, cls.ATTACH_FIELD_NAMES_AT)
244
+ return getattr(model_class, cls.ATTACH_FIELD_NAMES_AT)
245
245
 
246
246
  @staticmethod
247
247
  def get_field_value(instance, field_name):
@@ -270,7 +270,7 @@ class AuditEvent(models.Model):
270
270
  f"refusing to overwrite {cls.ATTACH_INIT_VALUES_AT!r} "
271
271
  f"on model instance: {instance}"
272
272
  )
273
- field_names = cls._field_names(instance)
273
+ field_names = cls.field_names(instance)
274
274
  init_values = {f: cls.get_field_value(instance, f) for f in field_names}
275
275
  setattr(instance, cls.ATTACH_INIT_VALUES_AT, init_values)
276
276
 
@@ -293,19 +293,82 @@ class AuditEvent(models.Model):
293
293
 
294
294
  @classmethod
295
295
  def audit_field_changes(cls, *args, **kw):
296
- """Convenience method that calls ``make_audit_event()`` and saves the
297
- event (if one is returned).
296
+ """Convenience method that calls ``make_audit_event_from_instance()``
297
+ and saves the event (if one is returned).
298
298
 
299
- All [keyword] arguments are passed directly to ``make_audit_event()``,
300
- see that method for usage.
299
+ All [keyword] arguments are passed directly to
300
+ ``make_audit_event_from_instance()``, see that method for usage.
301
301
  """
302
- event = cls.make_audit_event(*args, **kw)
302
+ event = cls.make_audit_event_from_instance(*args, **kw)
303
303
  if event is not None:
304
304
  event.save()
305
305
 
306
306
  @classmethod
307
- def make_audit_event(cls, instance, is_create, is_delete,
308
- request, object_pk=None, init_values=None):
307
+ def get_delta_from_instance(cls, instance, is_create, is_delete):
308
+ """
309
+ Returns a dictionary representing the delta of an instance of a model
310
+ being audited for changes.
311
+ Has the side effect of calling cls.reset_initial_values(instance)
312
+ which grabs and updates the initial values stored on the instance.
313
+
314
+ :param instance: instance of a Model subclass to be audited for changes
315
+ :param is_create: whether or not the audited event creates a new DB
316
+ record (setting ``True`` implies that ``instance`` is changing)
317
+ :param is_delete: whether or not the audited event deletes an existing
318
+ DB record (setting ``True`` implies that ``instance`` is changing)
319
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
320
+ :raises: ``AssertionError`` if both is_create and is_delete are true
321
+ """
322
+ assert not (is_create and is_delete),\
323
+ "is_create and is_delete cannot both be true"
324
+ fields_to_audit = cls.field_names(instance)
325
+ # SIDE EFFECT: fetch and reset initial values for next db write
326
+ init_values = cls.reset_initial_values(instance)
327
+ old_values = {} if is_create else init_values
328
+ new_values = {} if is_delete else \
329
+ {f: cls.get_field_value(instance, f) for f in fields_to_audit}
330
+ return cls.create_delta(old_values, new_values)
331
+
332
+ @staticmethod
333
+ def create_delta(old_values, new_values):
334
+ """
335
+ Compares two dictionaries and creates a delta between the two
336
+
337
+ :param old_values: {field_name: field_value, ...} representing the
338
+ values prior to a change
339
+ :param new_values: {field_name: field_value, ...} representing the
340
+ values after a change
341
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
342
+ :raises: ``AssertionError`` if both old_values and new_values are empty
343
+ do not match
344
+ """
345
+ assert old_values or new_values, \
346
+ "Must provide a non-empty value for either old_values or new_values"
347
+
348
+ changed_fields = old_values.keys() if old_values else new_values.keys()
349
+ if old_values and new_values:
350
+ changed_fields = new_values.keys()
351
+
352
+ delta = {}
353
+ for field_name in changed_fields:
354
+ if not old_values:
355
+ delta[field_name] = {"new": new_values[field_name]}
356
+ elif not new_values:
357
+ delta[field_name] = {"old": old_values[field_name]}
358
+ else:
359
+ try:
360
+ old_value = old_values[field_name]
361
+ except KeyError:
362
+ delta[field_name] = {"new": new_values[field_name]}
363
+ else:
364
+ if old_value != new_values[field_name]:
365
+ delta[field_name] = {"old": old_value,
366
+ "new": new_values[field_name]}
367
+ return delta
368
+
369
+ @classmethod
370
+ def make_audit_event_from_instance(cls, instance, is_create, is_delete,
371
+ request, object_pk=None):
309
372
  """Factory method for creating a new ``AuditEvent`` for an instance of a
310
373
  model that's being audited for changes.
311
374
 
@@ -320,9 +383,6 @@ class AuditEvent(models.Model):
320
383
  ``is_delete == True``, that is, when the instance itself no longer
321
384
  references its pre-delete primary key. It is ambiguous to set this
322
385
  when ``is_delete == False``, and doing so will raise an exception.
323
- :param init_values: (Optional) dictionary of initial values for audited
324
- fields on the provided instance. If not provided, will use
325
- AuditEvent.ATTACH_INIT_VALUES_AT attribute.
326
386
  :returns: an unsaved ``AuditEvent`` instance (or ``None`` if
327
387
  ``instance`` has not changed)
328
388
  :raises: ``ValueError`` on invalid use of the ``object_pk`` argument
@@ -333,37 +393,52 @@ class AuditEvent(models.Model):
333
393
  "'object_pk' arg is ambiguous when 'is_delete == False'"
334
394
  )
335
395
  object_pk = instance.pk
336
- # fetch (and reset for next db write operation) initial values
337
- fields_to_audit = init_values.keys() if init_values else \
338
- cls._field_names(instance)
339
- init_values = init_values or cls.reset_initial_values(instance)
340
- delta = {}
341
- for field_name in fields_to_audit:
342
- value = cls.get_field_value(instance, field_name)
343
- if is_create:
344
- delta[field_name] = {"new": value}
345
- elif is_delete:
346
- delta[field_name] = {"old": value}
347
- else:
348
- try:
349
- init_value = init_values[field_name]
350
- except KeyError:
351
- delta[field_name] = {"new": value}
352
- else:
353
- if init_value != value:
354
- delta[field_name] = {"old": init_value, "new": value}
396
+
397
+ delta = cls.get_delta_from_instance(instance, is_create, is_delete)
398
+
355
399
  if delta:
356
- from .auditors import audit_dispatcher
357
- from .field_audit import get_audited_class_path
358
- change_context = audit_dispatcher.dispatch(request)
359
- return cls(
360
- object_class_path=get_audited_class_path(type(instance)),
361
- object_pk=object_pk,
362
- change_context=cls._change_context_db_value(change_context),
363
- is_create=is_create,
364
- is_delete=is_delete,
365
- delta=delta,
366
- )
400
+ return cls.create_audit_event(object_pk, type(instance), delta,
401
+ is_create, is_delete, request)
402
+
403
+ @classmethod
404
+ def make_audit_event_from_values(cls, old_values, new_values, object_pk,
405
+ object_cls, request):
406
+ """Factory method for creating a new ``AuditEvent`` based on old and new
407
+ values.
408
+
409
+ :param old_values: {field_name: field_value, ...} representing the
410
+ values prior to a change
411
+ :param new_values: {field_name: field_value, ...} representing the
412
+ values after a change
413
+ :param object_pk: primary key of the instance
414
+ :param object_cls: class type of the object being audited
415
+ :param request: the request object responsible for the change (or
416
+ ``None`` if there is no request)
417
+ :returns: an unsaved ``AuditEvent`` instance (or ``None`` if
418
+ no difference between ``old_values`` and ``new_values``)
419
+ """
420
+ is_create = not old_values
421
+ is_delete = not new_values
422
+ delta = AuditEvent.create_delta(old_values, new_values)
423
+ if delta:
424
+ return AuditEvent.create_audit_event(object_pk, object_cls, delta,
425
+ is_create, is_delete, request)
426
+
427
+ @classmethod
428
+ def create_audit_event(cls, object_pk, object_cls, delta, is_create,
429
+ is_delete, request):
430
+ from .auditors import audit_dispatcher
431
+ from .field_audit import get_audited_class_path
432
+ change_context = audit_dispatcher.dispatch(request)
433
+ object_cls_path = get_audited_class_path(object_cls)
434
+ return cls(
435
+ object_class_path=object_cls_path,
436
+ object_pk=object_pk,
437
+ change_context=cls._change_context_db_value(change_context),
438
+ is_create=is_create,
439
+ is_delete=is_delete,
440
+ delta=delta,
441
+ )
367
442
 
368
443
  @classmethod
369
444
  def bootstrap_existing_model_records(cls, model_class, field_names,
@@ -490,7 +565,14 @@ def validate_audit_action(func):
490
565
  :raises: ``InvalidAuditActionError`` or ``UnsetAuditActionError``
491
566
  """
492
567
  @wraps(func)
493
- def wrapper(self, *args, audit_action=AuditAction.RAISE, **kw):
568
+ def wrapper(self, *args, **kw):
569
+ try:
570
+ audit_action = kw["audit_action"]
571
+ except KeyError:
572
+ raise UnsetAuditActionError(
573
+ f"{type(self).__name__}.{func.__name__}() requires an audit "
574
+ "action as a keyword argument."
575
+ )
494
576
  if audit_action not in AuditAction:
495
577
  raise InvalidAuditActionError(
496
578
  "The 'audit_action' argument must be a value of 'AuditAction', "
@@ -501,7 +583,7 @@ def validate_audit_action(func):
501
583
  f"{type(self).__name__}.{func.__name__}() requires an audit "
502
584
  "action"
503
585
  )
504
- return func(self, *args, audit_action=audit_action, **kw)
586
+ return func(self, *args, **kw)
505
587
  return wrapper
506
588
 
507
589
 
@@ -532,13 +614,23 @@ class AuditingQuerySet(models.QuerySet):
532
614
  """
533
615
 
534
616
  @validate_audit_action
535
- def bulk_create(self, *args, audit_action=AuditAction.RAISE, **kw):
536
- if audit_action is AuditAction.IGNORE:
537
- return super().bulk_create(*args, **kw)
538
- else:
539
- raise NotImplementedError(
540
- "Change auditing is not implemented for bulk_create()."
541
- )
617
+ def bulk_create(self, objs, *, audit_action=AuditAction.RAISE, **kw):
618
+ if audit_action is AuditAction.IGNORE or not objs:
619
+ return super().bulk_create(objs, **kw)
620
+ assert audit_action is AuditAction.AUDIT, audit_action
621
+
622
+ from .field_audit import request
623
+ request = request.get()
624
+
625
+ with transaction.atomic(using=self.db):
626
+ created_objs = super().bulk_create(objs, **kw)
627
+ audit_events = []
628
+ for obj in created_objs:
629
+ audit_events.append(
630
+ AuditEvent.make_audit_event_from_instance(
631
+ obj, True, False, request))
632
+ AuditEvent.objects.bulk_create(audit_events)
633
+ return created_objs
542
634
 
543
635
  @validate_audit_action
544
636
  def bulk_update(self, *args, audit_action=AuditAction.RAISE, **kw):
@@ -557,52 +649,66 @@ class AuditingQuerySet(models.QuerySet):
557
649
  from .field_audit import request
558
650
  request = request.get()
559
651
  audit_events = []
560
- for instance in self:
561
- # make_audit_event() will never return None because delete=True
562
- audit_events.append(AuditEvent.make_audit_event(
563
- instance,
564
- False,
565
- True,
566
- request,
567
- instance.pk,
568
- ))
569
- value = super().delete()
570
- if audit_events:
571
- # write the audit events _after_ the delete succeeds
572
- AuditEvent.objects.bulk_create(audit_events)
573
- return value
652
+ fields_to_fetch = set(AuditEvent.field_names(self.model)) | {'pk'}
653
+ current_values = {}
654
+ for values_for_instance in self.values(*fields_to_fetch):
655
+ pk = values_for_instance.pop('pk')
656
+ current_values[pk] = values_for_instance
657
+
658
+ for pk, current_values_for_pk in current_values.items():
659
+ audit_event = AuditEvent.make_audit_event_from_values(
660
+ current_values_for_pk,
661
+ {},
662
+ pk,
663
+ self.model,
664
+ request
665
+ )
666
+ audit_events.append(audit_event)
667
+
668
+ with transaction.atomic(using=self.db):
669
+ value = super().delete()
670
+ if audit_events:
671
+ # write the audit events _after_ the delete succeeds
672
+ AuditEvent.objects.bulk_create(audit_events)
673
+ return value
574
674
 
575
675
  @validate_audit_action
576
- def update(self, audit_action=AuditAction.RAISE, **kw):
676
+ def update(self, *, audit_action=AuditAction.RAISE, **kw):
677
+ """
678
+ In order to determine the old and new values of the records matched by
679
+ the queryset, a fetch of audited values for the matched records is
680
+ performed, resulting in one fetch of the current values, one update of
681
+ the matched records, and one bulk creation of audit events.
682
+ """
577
683
  if audit_action is AuditAction.IGNORE:
578
684
  return super().update(**kw)
579
685
  assert audit_action is AuditAction.AUDIT, audit_action
580
686
 
581
687
  fields_to_update = set(kw.keys())
582
- audited_fields = set(
583
- getattr(self.model, AuditEvent.ATTACH_FIELD_NAMES_AT)
584
- )
688
+ audited_fields = set(AuditEvent.field_names(self.model))
585
689
  fields_to_audit = fields_to_update & audited_fields
586
690
  if not fields_to_audit:
587
691
  # no audited fields are changing
588
692
  return super().update(**kw)
589
693
 
590
- values_to_fetch = fields_to_update | {"pk"}
694
+ new_values = {field: kw[field] for field in fields_to_audit}
695
+
591
696
  old_values = {}
697
+ values_to_fetch = fields_to_update | {"pk"}
592
698
  for value in self.values(*values_to_fetch):
593
699
  pk = value.pop('pk')
594
700
  old_values[pk] = value
595
701
 
596
- with transaction.atomic(using=router.db_for_write(self.model)):
702
+ with transaction.atomic(using=self.db):
597
703
  rows = super().update(**kw)
598
704
  # create and write the audit events _after_ the update succeeds
599
705
  from .field_audit import request
600
706
  request = request.get()
601
707
  audit_events = []
602
- for instance in self:
603
- init_values = old_values[instance.pk]
604
- audit_event = AuditEvent.make_audit_event(
605
- instance, False, False, request, init_values=init_values
708
+
709
+ for pk, old_values_for_pk in old_values.items():
710
+ audit_event = AuditEvent.make_audit_event_from_values(
711
+ old_values_for_pk, new_values, pk, self.model, request
606
712
  )
607
713
  if audit_event:
608
714
  audit_events.append(audit_event)