django-field-audit 1.2.3__tar.gz → 1.2.5__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.3 → django-field-audit-1.2.5}/PKG-INFO +10 -8
  2. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/README.md +9 -7
  3. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/django_field_audit.egg-info/PKG-INFO +10 -8
  4. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/__init__.py +1 -1
  5. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/field_audit.py +13 -10
  6. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/management/commands/bootstrap_field_audit_events.py +1 -9
  7. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/models.py +207 -70
  8. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/LICENSE +0 -0
  9. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/django_field_audit.egg-info/SOURCES.txt +0 -0
  10. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/django_field_audit.egg-info/dependency_links.txt +0 -0
  11. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/django_field_audit.egg-info/top_level.txt +0 -0
  12. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/apps.py +0 -0
  13. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/auditors.py +0 -0
  14. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/const.py +0 -0
  15. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/management/__init__.py +0 -0
  16. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/management/commands/__init__.py +0 -0
  17. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/middleware.py +0 -0
  18. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/migrations/0001_initial.py +0 -0
  19. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
  20. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/migrations/__init__.py +0 -0
  21. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/field_audit/utils.py +0 -0
  22. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/setup.cfg +0 -0
  23. {django-field-audit-1.2.3 → django-field-audit-1.2.5}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-field-audit
3
- Version: 1.2.3
3
+ Version: 1.2.5
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,12 +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()` "special" write method can actually
155
- perform change auditing when called with `audit_action=AuditAction.AUDIT`. The
156
- other three methods are currently not implemented and will raise
157
- `NotImplementedError` if called with that action. Implementing these remaining
158
- methods remains a task for the future, see **TODO** below. All four methods do
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
159
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.
160
164
 
161
165
  #### Bootstrap events for models with existing records
162
166
 
@@ -275,9 +279,7 @@ twine upload dist/*
275
279
  ## TODO
276
280
 
277
281
  - Implement auditing for the remaining "special" QuerySet write operations:
278
- - `bulk_create()`
279
282
  - `bulk_update()`
280
- - `update()`
281
283
  - Write full library documentation using github.io.
282
284
  - Switch to `pytest` to support Python 3.10.
283
285
 
@@ -124,12 +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()` "special" write method can actually
128
- perform change auditing when called with `audit_action=AuditAction.AUDIT`. The
129
- other three methods are currently not implemented and will raise
130
- `NotImplementedError` if called with that action. Implementing these remaining
131
- methods remains a task for the future, see **TODO** below. All four methods do
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
132
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.
133
137
 
134
138
  #### Bootstrap events for models with existing records
135
139
 
@@ -248,9 +252,7 @@ twine upload dist/*
248
252
  ## TODO
249
253
 
250
254
  - Implement auditing for the remaining "special" QuerySet write operations:
251
- - `bulk_create()`
252
255
  - `bulk_update()`
253
- - `update()`
254
256
  - Write full library documentation using github.io.
255
257
  - Switch to `pytest` to support Python 3.10.
256
258
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-field-audit
3
- Version: 1.2.3
3
+ Version: 1.2.5
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,12 +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()` "special" write method can actually
155
- perform change auditing when called with `audit_action=AuditAction.AUDIT`. The
156
- other three methods are currently not implemented and will raise
157
- `NotImplementedError` if called with that action. Implementing these remaining
158
- methods remains a task for the future, see **TODO** below. All four methods do
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
159
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.
160
164
 
161
165
  #### Bootstrap events for models with existing records
162
166
 
@@ -275,9 +279,7 @@ twine upload dist/*
275
279
  ## TODO
276
280
 
277
281
  - Implement auditing for the remaining "special" QuerySet write operations:
278
- - `bulk_create()`
279
282
  - `bulk_update()`
280
- - `update()`
281
283
  - Write full library documentation using github.io.
282
284
  - Switch to `pytest` to support Python 3.10.
283
285
 
@@ -1,3 +1,3 @@
1
1
  from .field_audit import audit_fields # noqa: F401
2
2
 
3
- __version__ = "1.2.3"
3
+ __version__ = "1.2.5"
@@ -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
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,79 @@ 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):
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
+
312
+ :param instance: instance of a Model subclass to be audited for changes
313
+ :param is_create: whether or not the audited event creates a new DB
314
+ record (setting ``True`` implies that ``instance`` is changing)
315
+ :param is_delete: whether or not the audited event deletes an existing
316
+ DB record (setting ``True`` implies that ``instance`` is changing)
317
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
318
+ :raises: ``AssertionError`` if both is_create and is_delete are true
319
+ """
320
+ assert not (is_create and is_delete),\
321
+ "is_create and is_delete cannot both be true"
322
+ fields_to_audit = cls.field_names(instance)
323
+ # fetch (and reset for next db write operation) initial values
324
+ old_values = {} if is_create else cls.reset_initial_values(instance)
325
+ new_values = {} if is_delete else \
326
+ {f: cls.get_field_value(instance, f) for f in fields_to_audit}
327
+ return cls.create_delta(old_values, new_values)
328
+
329
+ @staticmethod
330
+ def create_delta(old_values, new_values):
331
+ """
332
+ Compares two dictionaries and creates a delta between the two
333
+
334
+ :param old_values: {field_name: field_value, ...} representing the
335
+ values prior to a change
336
+ :param new_values: {field_name: field_value, ...} representing the
337
+ values after a change
338
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
339
+ :raises: ``AssertionError`` if both old_values and new_values are empty
340
+ do not match
341
+ """
342
+ assert old_values or new_values, \
343
+ "Must provide a non-empty value for either old_values or new_values"
344
+
345
+ changed_fields = old_values.keys() if old_values else new_values.keys()
346
+ if old_values and new_values:
347
+ changed_fields = new_values.keys()
348
+
349
+ delta = {}
350
+ for field_name in changed_fields:
351
+ if not old_values:
352
+ delta[field_name] = {"new": new_values[field_name]}
353
+ elif not new_values:
354
+ delta[field_name] = {"old": old_values[field_name]}
355
+ else:
356
+ try:
357
+ old_value = old_values[field_name]
358
+ except KeyError:
359
+ delta[field_name] = {"new": new_values[field_name]}
360
+ else:
361
+ if old_value != new_values[field_name]:
362
+ delta[field_name] = {"old": old_value,
363
+ "new": new_values[field_name]}
364
+ return delta
365
+
366
+ @classmethod
367
+ def make_audit_event_from_instance(cls, instance, is_create, is_delete,
368
+ request, object_pk=None):
309
369
  """Factory method for creating a new ``AuditEvent`` for an instance of a
310
370
  model that's being audited for changes.
311
371
 
@@ -330,35 +390,52 @@ class AuditEvent(models.Model):
330
390
  "'object_pk' arg is ambiguous when 'is_delete == False'"
331
391
  )
332
392
  object_pk = instance.pk
333
- # fetch (and reset for next db write operation) initial values
334
- init_values = cls.reset_initial_values(instance)
335
- delta = {}
336
- for field_name in cls._field_names(instance):
337
- value = cls.get_field_value(instance, field_name)
338
- if is_create:
339
- delta[field_name] = {"new": value}
340
- elif is_delete:
341
- delta[field_name] = {"old": value}
342
- else:
343
- try:
344
- init_value = init_values[field_name]
345
- except KeyError:
346
- delta[field_name] = {"new": value}
347
- else:
348
- if init_value != value:
349
- delta[field_name] = {"old": init_value, "new": value}
393
+
394
+ delta = cls.get_delta_from_instance(instance, is_create, is_delete)
395
+
350
396
  if delta:
351
- from .auditors import audit_dispatcher
352
- from .field_audit import get_audited_class_path
353
- change_context = audit_dispatcher.dispatch(request)
354
- return cls(
355
- object_class_path=get_audited_class_path(type(instance)),
356
- object_pk=object_pk,
357
- change_context=cls._change_context_db_value(change_context),
358
- is_create=is_create,
359
- is_delete=is_delete,
360
- delta=delta,
361
- )
397
+ return cls.create_audit_event(object_pk, type(instance), delta,
398
+ is_create, is_delete, request)
399
+
400
+ @classmethod
401
+ def make_audit_event_from_values(cls, old_values, new_values, object_pk,
402
+ object_cls, request):
403
+ """Factory method for creating a new ``AuditEvent`` based on old and new
404
+ values.
405
+
406
+ :param old_values: {field_name: field_value, ...} representing the
407
+ values prior to a change
408
+ :param new_values: {field_name: field_value, ...} representing the
409
+ values after a change
410
+ :param object_pk: primary key of the instance
411
+ :param object_cls: class type of the object being audited
412
+ :param request: the request object responsible for the change (or
413
+ ``None`` if there is no request)
414
+ :returns: an unsaved ``AuditEvent`` instance (or ``None`` if
415
+ no difference between ``old_values`` and ``new_values``)
416
+ """
417
+ is_create = not old_values
418
+ is_delete = not new_values
419
+ delta = AuditEvent.create_delta(old_values, new_values)
420
+ if delta:
421
+ return AuditEvent.create_audit_event(object_pk, object_cls, delta,
422
+ is_create, is_delete, request)
423
+
424
+ @classmethod
425
+ def create_audit_event(cls, object_pk, object_cls, delta, is_create,
426
+ is_delete, request):
427
+ from .auditors import audit_dispatcher
428
+ from .field_audit import get_audited_class_path
429
+ change_context = audit_dispatcher.dispatch(request)
430
+ object_cls_path = get_audited_class_path(object_cls)
431
+ return cls(
432
+ object_class_path=object_cls_path,
433
+ object_pk=object_pk,
434
+ change_context=cls._change_context_db_value(change_context),
435
+ is_create=is_create,
436
+ is_delete=is_delete,
437
+ delta=delta,
438
+ )
362
439
 
363
440
  @classmethod
364
441
  def bootstrap_existing_model_records(cls, model_class, field_names,
@@ -485,7 +562,14 @@ def validate_audit_action(func):
485
562
  :raises: ``InvalidAuditActionError`` or ``UnsetAuditActionError``
486
563
  """
487
564
  @wraps(func)
488
- def wrapper(self, *args, audit_action=AuditAction.RAISE, **kw):
565
+ def wrapper(self, *args, **kw):
566
+ try:
567
+ audit_action = kw["audit_action"]
568
+ except KeyError:
569
+ raise UnsetAuditActionError(
570
+ f"{type(self).__name__}.{func.__name__}() requires an audit "
571
+ "action as a keyword argument."
572
+ )
489
573
  if audit_action not in AuditAction:
490
574
  raise InvalidAuditActionError(
491
575
  "The 'audit_action' argument must be a value of 'AuditAction', "
@@ -496,7 +580,7 @@ def validate_audit_action(func):
496
580
  f"{type(self).__name__}.{func.__name__}() requires an audit "
497
581
  "action"
498
582
  )
499
- return func(self, *args, audit_action=audit_action, **kw)
583
+ return func(self, *args, **kw)
500
584
  return wrapper
501
585
 
502
586
 
@@ -527,13 +611,23 @@ class AuditingQuerySet(models.QuerySet):
527
611
  """
528
612
 
529
613
  @validate_audit_action
530
- def bulk_create(self, *args, audit_action=AuditAction.RAISE, **kw):
531
- if audit_action is AuditAction.IGNORE:
532
- return super().bulk_create(*args, **kw)
533
- else:
534
- raise NotImplementedError(
535
- "Change auditing is not implemented for bulk_create()."
536
- )
614
+ def bulk_create(self, objs, *, audit_action=AuditAction.RAISE, **kw):
615
+ if audit_action is AuditAction.IGNORE or not objs:
616
+ return super().bulk_create(objs, **kw)
617
+ assert audit_action is AuditAction.AUDIT, audit_action
618
+
619
+ from .field_audit import request
620
+ request = request.get()
621
+
622
+ with transaction.atomic(using=self.db):
623
+ created_objs = super().bulk_create(objs, **kw)
624
+ audit_events = []
625
+ for obj in created_objs:
626
+ audit_events.append(
627
+ AuditEvent.make_audit_event_from_instance(
628
+ obj, True, False, request))
629
+ AuditEvent.objects.bulk_create(audit_events)
630
+ return created_objs
537
631
 
538
632
  @validate_audit_action
539
633
  def bulk_update(self, *args, audit_action=AuditAction.RAISE, **kw):
@@ -552,29 +646,72 @@ class AuditingQuerySet(models.QuerySet):
552
646
  from .field_audit import request
553
647
  request = request.get()
554
648
  audit_events = []
555
- for instance in self:
556
- # make_audit_event() will never return None because delete=True
557
- audit_events.append(AuditEvent.make_audit_event(
558
- instance,
559
- False,
560
- True,
561
- request,
562
- instance.pk,
563
- ))
564
- value = super().delete()
565
- if audit_events:
566
- # write the audit events _after_ the delete succeeds
567
- AuditEvent.objects.bulk_create(audit_events)
568
- return value
649
+ fields_to_fetch = set(AuditEvent.field_names(self.model)) | {'pk'}
650
+ current_values = {}
651
+ for values_for_instance in self.values(*fields_to_fetch):
652
+ pk = values_for_instance.pop('pk')
653
+ current_values[pk] = values_for_instance
654
+
655
+ for pk, current_values_for_pk in current_values.items():
656
+ audit_event = AuditEvent.make_audit_event_from_values(
657
+ current_values_for_pk,
658
+ {},
659
+ pk,
660
+ self.model,
661
+ request
662
+ )
663
+ audit_events.append(audit_event)
664
+
665
+ with transaction.atomic(using=self.db):
666
+ value = super().delete()
667
+ if audit_events:
668
+ # write the audit events _after_ the delete succeeds
669
+ AuditEvent.objects.bulk_create(audit_events)
670
+ return value
569
671
 
570
672
  @validate_audit_action
571
- def update(self, *args, audit_action=AuditAction.RAISE, **kw):
673
+ def update(self, *, audit_action=AuditAction.RAISE, **kw):
674
+ """
675
+ In order to determine the old and new values of the records matched by
676
+ the queryset, a fetch of audited values for the matched records is
677
+ performed, resulting in one fetch of the current values, one update of
678
+ the matched records, and one bulk creation of audit events.
679
+ """
572
680
  if audit_action is AuditAction.IGNORE:
573
- return super().update(*args, **kw)
574
- else:
575
- raise NotImplementedError(
576
- "Change auditing is not implemented for update()."
577
- )
681
+ return super().update(**kw)
682
+ assert audit_action is AuditAction.AUDIT, audit_action
683
+
684
+ fields_to_update = set(kw.keys())
685
+ audited_fields = set(AuditEvent.field_names(self.model))
686
+ fields_to_audit = fields_to_update & audited_fields
687
+ if not fields_to_audit:
688
+ # no audited fields are changing
689
+ return super().update(**kw)
690
+
691
+ new_values = {field: kw[field] for field in fields_to_audit}
692
+
693
+ old_values = {}
694
+ values_to_fetch = fields_to_update | {"pk"}
695
+ for value in self.values(*values_to_fetch):
696
+ pk = value.pop('pk')
697
+ old_values[pk] = value
698
+
699
+ with transaction.atomic(using=self.db):
700
+ rows = super().update(**kw)
701
+ # create and write the audit events _after_ the update succeeds
702
+ from .field_audit import request
703
+ request = request.get()
704
+ audit_events = []
705
+
706
+ for pk, old_values_for_pk in old_values.items():
707
+ audit_event = AuditEvent.make_audit_event_from_values(
708
+ old_values_for_pk, new_values, pk, self.model, request
709
+ )
710
+ if audit_event:
711
+ audit_events.append(audit_event)
712
+ if audit_events:
713
+ AuditEvent.objects.bulk_create(audit_events)
714
+ return rows
578
715
 
579
716
 
580
717
  AuditingManager = models.Manager.from_queryset(AuditingQuerySet)