django-field-audit 1.4.0__py3-none-any.whl

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.

field_audit/models.py ADDED
@@ -0,0 +1,824 @@
1
+ import warnings
2
+ from enum import Enum
3
+ from functools import wraps
4
+
5
+ from django.conf import settings
6
+ from django.core.serializers.json import DjangoJSONEncoder
7
+ from django.db import models, transaction
8
+ from django.db.models import Expression
9
+ from django.utils import timezone
10
+
11
+ from .const import BOOTSTRAP_BATCH_SIZE
12
+ from .utils import class_import_helper
13
+
14
+ USER_TYPE_TTY = "SystemTtyOwner",
15
+ USER_TYPE_PROCESS = "SystemProcessOwner"
16
+ USER_TYPE_REQUEST = "RequestUser"
17
+
18
+
19
+ def check_engine_sqlite(engine=None):
20
+ """Check if SQLite (or Oracle) database engines are in use. If ``engine`` is
21
+ ``None``, check all Django engines (otherwise check only ``engine``).
22
+
23
+ :param engine: (Optional) name of a Django database engine.
24
+ """
25
+ def lite_it_up(engine):
26
+ # check if engine "flavor" is Oracle or SQLite
27
+ # example db engine: django.db.backends.sqlite3
28
+ # resulting flavor: sqlite
29
+ return engine.split(".")[-1][:6] in {"sqlite", "oracle"}
30
+ if engine is None:
31
+ for db_properties in settings.DATABASES.values():
32
+ # The following "no branch" directive prevents coverage from
33
+ # reporting the (known) untested branch of
34
+ # `if lite_it_up(...)->return`. There will always be an uncovered
35
+ # branch here because tests only run on postgres _or_ sqlite, never
36
+ # both.
37
+ if lite_it_up(db_properties["ENGINE"]): # pragma: no branch
38
+ return True
39
+ else:
40
+ return lite_it_up(engine)
41
+ # "no cover" note: tests only run on postgres _or_ sqlite, never both
42
+ return False # pragma: no cover
43
+
44
+
45
+ class AuditEventManager(models.Manager):
46
+ """Manager for the AuditEvent model."""
47
+
48
+ def by_model(self, model_class):
49
+ """Filter records for a specific model.
50
+
51
+ :param model_class: an audited Django model class
52
+ :returns: ``QuerySet``
53
+ """
54
+ from .field_audit import get_audited_class_path
55
+ return self.filter(
56
+ object_class_path=get_audited_class_path(model_class)
57
+ )
58
+
59
+ def cast_object_pk_for_model(self, model_class):
60
+ """Filter records for a specific model and add an ``as_pk_type``
61
+ expression column containing the ``object_pk`` values cast to the PK
62
+ type of ``model_class``.
63
+
64
+ :param model_class: an audited Django model class
65
+ :returns: ``QuerySet``
66
+ """
67
+ if type(model_class._meta.pk) is models.JSONField:
68
+ expression = models.F("object_pk")
69
+ else:
70
+ expression = CastFromJson("object_pk", model_class._meta.pk)
71
+ return self.by_model(model_class).annotate(as_pk_type=expression)
72
+
73
+ def cast_object_pks_list(self, model_class):
74
+ """Convenience method for getting the results of
75
+ ``cast_object_pk_for_model(...)`` as a values list.
76
+
77
+ Example:
78
+ >>> SomeModel.objects.filter(pk_in=(
79
+ AuditEvent.objects
80
+ .filter(event_date__gte=datetime.date.today())
81
+ .cast_object_pks_list(SomeModel)
82
+ ))
83
+
84
+ :param model_class: an audited Django model class
85
+ :param flat: optional argument passed to the
86
+ ``values_list()`` method (default=True).
87
+ """
88
+ return (
89
+ self.cast_object_pk_for_model(model_class)
90
+ .values_list("as_pk_type", flat=True)
91
+ )
92
+
93
+ def by_type_and_username(self, user_type, username):
94
+ """Use the ``contains`` query (PostgreSQL and MySQL/MariaDB only) to
95
+ query for documents with matching keys.
96
+
97
+ If other DB flavors are configured in Django settings, support is
98
+ defined at import time (see below)
99
+ """
100
+ # "no cover" note: tests only run on postgres _or_ sqlite, never both
101
+ return self.filter( # pragma: no cover
102
+ change_context__contains={
103
+ "user_type": user_type,
104
+ "username": username,
105
+ },
106
+ )
107
+
108
+ # "no branch" note: tests only run on postgres _or_ sqlite, never both
109
+ if check_engine_sqlite(): # pragma: no branch
110
+ _by_type_and_username = by_type_and_username
111
+
112
+ def by_type_and_username(self, user_type, username):
113
+ """Support SQLite (for development) and Oracle (because it comes
114
+ along for free).
115
+
116
+ If these DB flavors are not needed at import time, this "extra db"
117
+ support is never defined.
118
+ """
119
+ if check_engine_sqlite(settings.DATABASES[self.db]["ENGINE"]):
120
+ # Oracle and SQLite do not support `contains` queries
121
+ # see: https://docs.djangoproject.com/en/4.0/topics/db/queries/#std:fieldlookup-jsonfield.contains # noqa: E501
122
+ return self.filter(
123
+ change_context__user_type=user_type,
124
+ change_context__username=username,
125
+ )
126
+ # "no cover" note: tests only run on postgres _or_ sqlite, never
127
+ # both
128
+ return self._by_type_and_username(user_type, username) # pragma: no cover # noqa: E501
129
+
130
+
131
+ class CastFromJson(models.functions.comparison.Cast):
132
+
133
+ def __init__(self, expression, output_field):
134
+ super().__init__(JsonPreCast(expression), output_field)
135
+
136
+
137
+ class JsonPreCast(models.expressions.Func):
138
+ """A function that works on the JSON type and prepares it such that it can
139
+ be cast to other types."""
140
+
141
+ template = "%(expressions)s"
142
+ arity = 1
143
+
144
+ def as_postgresql(self, compiler, connection, **extra_context):
145
+ sql, params = self.as_sql(compiler, connection, **extra_context)
146
+ return f"({sql} #>> '{{}}')", params
147
+
148
+ def as_sqlite(self, compiler, connection, **extra_context):
149
+ sql, params = self.as_sql(compiler, connection, **extra_context)
150
+ return f"JSON_EXTRACT({sql}, '$')", params
151
+
152
+
153
+ class DefaultAuditEventManager(AuditEventManager):
154
+ """Default Manager for the AuditEvent model. Contains convenience methods
155
+ for the default auditors, which may not be desirable to subclass if
156
+ downstream projects wish to define custom auditor chains.
157
+ """
158
+
159
+ def by_system_user(self, username):
160
+ system_types = [USER_TYPE_TTY, USER_TYPE_PROCESS]
161
+ return self.filter(
162
+ change_context__user_type__in=system_types,
163
+ change_context__username=username,
164
+ )
165
+
166
+ def by_tty_user(self, username):
167
+ return self.by_type_and_username(USER_TYPE_TTY, username)
168
+
169
+ def by_process_user(self, username):
170
+ return self.by_type_and_username(USER_TYPE_PROCESS, username)
171
+
172
+ def by_request_user(self, username):
173
+ return self.by_type_and_username(USER_TYPE_REQUEST, username)
174
+
175
+
176
+ def get_manager(attr_suffix, default):
177
+ """Returns an instantiated manager, possibly one defined in settings.
178
+
179
+ If the manager is defined in settings, it must be a subclass of
180
+ ``django.db.models.Manager``.
181
+
182
+ :param attr_suffix: Suffix (appended to ``FIELD_AUDIT_``) of settings
183
+ attribute for the manager class path.
184
+ :param default: Manager class. Returned if attribute isn't defined.
185
+ """
186
+ settings_attr = f"FIELD_AUDIT_{attr_suffix}"
187
+ try:
188
+ class_path = getattr(settings, settings_attr)
189
+ except AttributeError:
190
+ return default()
191
+ desc = f"{settings_attr!r} value"
192
+ return class_import_helper(class_path, desc, models.Manager)()
193
+
194
+
195
+ def get_date():
196
+ """Returns the current UTC date/time.
197
+
198
+ This is the "getter" for default values of the ``AuditEvent.event_date``
199
+ field.
200
+ """
201
+ return timezone.now()
202
+
203
+
204
+ class AuditEvent(models.Model):
205
+ event_date = models.DateTimeField(default=get_date, db_index=True)
206
+ object_class_path = models.CharField(db_index=True, max_length=255)
207
+ object_pk = models.JSONField(encoder=DjangoJSONEncoder)
208
+ change_context = models.JSONField(encoder=DjangoJSONEncoder)
209
+ is_create = models.BooleanField(default=False)
210
+ is_delete = models.BooleanField(default=False)
211
+ is_bootstrap = models.BooleanField(default=False)
212
+ delta = models.JSONField(encoder=DjangoJSONEncoder)
213
+
214
+ objects = get_manager("AUDITEVENT_MANAGER", DefaultAuditEventManager)
215
+
216
+ class Meta:
217
+ constraints = [
218
+ models.CheckConstraint(
219
+ name="field_audit_auditevent_chk_create_or_delete_or_bootstrap",
220
+ check=~(
221
+ models.Q(is_create=True, is_delete=True) | \
222
+ models.Q(is_create=True, is_bootstrap=True) | \
223
+ models.Q(is_delete=True, is_bootstrap=True) # noqa: E502
224
+ ),
225
+ ),
226
+ ]
227
+
228
+ @classmethod
229
+ def attach_field_names(cls, model_class, field_names):
230
+ """Attaches a collection of field names to a Model class for auditing.
231
+
232
+ :param model_class: a Django Model class under audit
233
+ :param field_names: collection of field names to audit on the model
234
+
235
+ .. deprecated:: 1.4
236
+ Use AuditService.attach_field_names() instead.
237
+ """
238
+ warnings.warn(
239
+ "AuditEvent.attach_field_names() is deprecated. "
240
+ "Use AuditService.attach_field_names() instead.",
241
+ DeprecationWarning,
242
+ stacklevel=2
243
+ )
244
+ from .services import get_audit_service
245
+ service = get_audit_service()
246
+ return service.attach_field_names(model_class, field_names)
247
+
248
+ @classmethod
249
+ def field_names(cls, model_class):
250
+ """Returns the audit field names stored on the audited Model class
251
+
252
+ :param model_class: a Django Model class under audit
253
+
254
+ .. deprecated:: 1.4
255
+ Use AuditService.get_field_names() instead.
256
+ """
257
+ warnings.warn(
258
+ "AuditEvent.field_names() is deprecated. "
259
+ "Use AuditService.get_field_names() instead.",
260
+ DeprecationWarning,
261
+ stacklevel=2
262
+ )
263
+ from .services import get_audit_service
264
+ service = get_audit_service()
265
+ return service.get_field_names(model_class)
266
+
267
+ @staticmethod
268
+ def get_field_value(instance, field_name, bootstrap=False):
269
+ """Returns the database value of a field on ``instance``.
270
+
271
+ :param instance: an instance of a Django model
272
+ :param field_name: name of a field on ``instance``
273
+
274
+ .. deprecated:: 1.4
275
+ Use AuditService.get_field_value() instead.
276
+ """
277
+ warnings.warn(
278
+ "AuditEvent.get_field_value() is deprecated. "
279
+ "Use AuditService.get_field_value() instead.",
280
+ DeprecationWarning,
281
+ stacklevel=2
282
+ )
283
+ from .services import get_audit_service
284
+ service = get_audit_service()
285
+ return service.get_field_value(instance, field_name, bootstrap)
286
+
287
+ @classmethod
288
+ def attach_initial_values(cls, instance):
289
+ """Save copies of field values on an instance so they can be used later
290
+ to determine if the instance has changed and record what the previous
291
+ values were.
292
+
293
+ :param instance: instance of a Model subclass to be audited for changes
294
+ :raises: ``AttachValuesError`` if initial values are already attached to
295
+ the instance
296
+
297
+ .. deprecated:: 1.4
298
+ Use AuditService.attach_initial_values() instead.
299
+ """
300
+ warnings.warn(
301
+ "AuditEvent.attach_initial_values() is deprecated. "
302
+ "Use AuditService.attach_initial_values() instead.",
303
+ DeprecationWarning,
304
+ stacklevel=2
305
+ )
306
+ from .services import get_audit_service
307
+ service = get_audit_service()
308
+ return service.attach_initial_values(instance)
309
+
310
+ @classmethod
311
+ def attach_initial_m2m_values(cls, instance, field_name):
312
+ """.. deprecated:: 1.4
313
+ Use AuditService.attach_initial_m2m_values() instead.
314
+ """
315
+ warnings.warn(
316
+ "AuditEvent.attach_initial_m2m_values() is deprecated. "
317
+ "Use AuditService.attach_initial_m2m_values() instead.",
318
+ DeprecationWarning,
319
+ stacklevel=2
320
+ )
321
+ from .services import get_audit_service
322
+ service = get_audit_service()
323
+ return service.attach_initial_m2m_values(instance, field_name)
324
+
325
+ @classmethod
326
+ def get_initial_m2m_values(cls, instance, field_name):
327
+ """.. deprecated:: 1.4
328
+ Use AuditService.get_initial_m2m_values() instead.
329
+ """
330
+ warnings.warn(
331
+ "AuditEvent.get_initial_m2m_values() is deprecated. "
332
+ "Use AuditService.get_initial_m2m_values() instead.",
333
+ DeprecationWarning,
334
+ stacklevel=2
335
+ )
336
+ from .services import get_audit_service
337
+ service = get_audit_service()
338
+ return service.get_initial_m2m_values(instance, field_name)
339
+
340
+ @classmethod
341
+ def clear_initial_m2m_field_values(cls, instance, field_name):
342
+ """.. deprecated:: 1.4
343
+ Use AuditService.clear_initial_m2m_field_values() instead.
344
+ """
345
+ warnings.warn(
346
+ "AuditEvent.clear_initial_m2m_field_values() is deprecated. "
347
+ "Use AuditService.clear_initial_m2m_field_values() instead.",
348
+ DeprecationWarning,
349
+ stacklevel=2
350
+ )
351
+ from .services import get_audit_service
352
+ service = get_audit_service()
353
+ return service.clear_initial_m2m_field_values(instance, field_name)
354
+
355
+ @classmethod
356
+ def get_m2m_field_value(cls, instance, field_name):
357
+ """.. deprecated:: 1.4
358
+ Use AuditService.get_m2m_field_value() instead.
359
+ """
360
+ warnings.warn(
361
+ "AuditEvent.get_m2m_field_value() is deprecated. "
362
+ "Use AuditService.get_m2m_field_value() instead.",
363
+ DeprecationWarning,
364
+ stacklevel=2
365
+ )
366
+ from .services import get_audit_service
367
+ service = get_audit_service()
368
+ return service.get_m2m_field_value(instance, field_name)
369
+
370
+ @classmethod
371
+ def reset_initial_values(cls, instance):
372
+ """Returns the previously attached "initial values" and attaches new
373
+ values.
374
+
375
+ :param instance: instance of a Model subclass to be audited for changes
376
+ :raises: ``AttachValuesError`` if initial values are not attached to
377
+ the instance
378
+
379
+ .. deprecated:: 1.4
380
+ Use AuditService.reset_initial_values() instead.
381
+ """
382
+ warnings.warn(
383
+ "AuditEvent.reset_initial_values() is deprecated. "
384
+ "Use AuditService.reset_initial_values() instead.",
385
+ DeprecationWarning,
386
+ stacklevel=2
387
+ )
388
+ from .services import get_audit_service
389
+ service = get_audit_service()
390
+ return service.reset_initial_values(instance)
391
+
392
+ @classmethod
393
+ def audit_field_changes(cls, *args, **kw):
394
+ """Convenience method that calls ``make_audit_event_from_instance()``
395
+ and saves the event (if one is returned).
396
+
397
+ All [keyword] arguments are passed directly to
398
+ ``make_audit_event_from_instance()``, see that method for usage.
399
+
400
+ .. deprecated:: 1.4
401
+ Use AuditService.audit_field_changes() instead.
402
+ """
403
+ warnings.warn(
404
+ "AuditEvent.audit_field_changes() is deprecated. "
405
+ "Use AuditService.audit_field_changes() instead.",
406
+ DeprecationWarning,
407
+ stacklevel=2
408
+ )
409
+ from .services import get_audit_service
410
+ service = get_audit_service()
411
+ return service.audit_field_changes(*args, **kw)
412
+
413
+ @classmethod
414
+ def get_delta_from_instance(cls, instance, is_create, is_delete):
415
+ """
416
+ Returns a dictionary representing the delta of an instance of a model
417
+ being audited for changes.
418
+ Has the side effect of calling cls.reset_initial_values(instance)
419
+ which grabs and updates the initial values stored on the instance.
420
+
421
+ :param instance: instance of a Model subclass to be audited for changes
422
+ :param is_create: whether or not the audited event creates a new DB
423
+ record (setting ``True`` implies that ``instance`` is changing)
424
+ :param is_delete: whether or not the audited event deletes an existing
425
+ DB record (setting ``True`` implies that ``instance`` is changing)
426
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
427
+ :raises: ``AssertionError`` if both is_create and is_delete are true
428
+
429
+ .. deprecated:: 1.4
430
+ Use AuditService.get_delta_from_instance() instead.
431
+ """
432
+ warnings.warn(
433
+ "AuditEvent.get_delta_from_instance() is deprecated. "
434
+ "Use AuditService.get_delta_from_instance() instead.",
435
+ DeprecationWarning,
436
+ stacklevel=2
437
+ )
438
+ from .services import get_audit_service
439
+ service = get_audit_service()
440
+ return service.get_delta_from_instance(instance, is_create, is_delete)
441
+
442
+ @staticmethod
443
+ def create_delta(old_values, new_values):
444
+ """
445
+ Compares two dictionaries and creates a delta between the two
446
+
447
+ :param old_values: {field_name: field_value, ...} representing the
448
+ values prior to a change
449
+ :param new_values: {field_name: field_value, ...} representing the
450
+ values after a change
451
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
452
+ :raises: ``AssertionError`` if both old_values and new_values are empty
453
+ do not match
454
+
455
+ .. deprecated:: 1.4
456
+ Use AuditService.create_delta() instead.
457
+ """
458
+ warnings.warn(
459
+ "AuditEvent.create_delta() is deprecated. "
460
+ "Use AuditService.create_delta() instead.",
461
+ DeprecationWarning,
462
+ stacklevel=2,
463
+ )
464
+ from .services import get_audit_service
465
+ service = get_audit_service()
466
+ return service.create_delta(old_values, new_values)
467
+
468
+ @classmethod
469
+ def make_audit_event_from_instance(cls, instance, is_create, is_delete,
470
+ request, object_pk=None):
471
+ """Factory method for creating a new ``AuditEvent`` for an instance of a
472
+ model that's being audited for changes.
473
+
474
+ :param instance: instance of a Model subclass to be audited for changes
475
+ :param is_create: whether or not the audited event creates a new DB
476
+ record (setting ``True`` implies that ``instance`` is changing)
477
+ :param is_delete: whether or not the audited event deletes an existing
478
+ DB record (setting ``True`` implies that ``instance`` is changing)
479
+ :param request: the request object responsible for the change (or
480
+ ``None`` if there is no request)
481
+ :param object_pk: (Optional) primary key of the instance. Only used when
482
+ ``is_delete == True``, that is, when the instance itself no longer
483
+ references its pre-delete primary key. It is ambiguous to set this
484
+ when ``is_delete == False``, and doing so will raise an exception.
485
+ :returns: an unsaved ``AuditEvent`` instance (or ``None`` if
486
+ ``instance`` has not changed)
487
+ :raises: ``ValueError`` on invalid use of the ``object_pk`` argument
488
+
489
+ .. deprecated:: 1.4
490
+ Use AuditService.make_audit_event_from_instance() instead.
491
+ """
492
+ warnings.warn(
493
+ "AuditEvent.make_audit_event_from_instance() is deprecated. "
494
+ "Use AuditService.make_audit_event_from_instance() instead.",
495
+ DeprecationWarning,
496
+ stacklevel=2
497
+ )
498
+ from .services import get_audit_service
499
+ service = get_audit_service()
500
+ return service.make_audit_event_from_instance(
501
+ instance, is_create, is_delete, request, object_pk
502
+ )
503
+
504
+ @classmethod
505
+ def make_audit_event_from_values(cls, old_values, new_values, object_pk,
506
+ object_cls, request):
507
+ """Factory method for creating a new ``AuditEvent`` based on old and new
508
+ values.
509
+
510
+ :param old_values: {field_name: field_value, ...} representing the
511
+ values prior to a change
512
+ :param new_values: {field_name: field_value, ...} representing the
513
+ values after a change
514
+ :param object_pk: primary key of the instance
515
+ :param object_cls: class type of the object being audited
516
+ :param request: the request object responsible for the change (or
517
+ ``None`` if there is no request)
518
+ :returns: an unsaved ``AuditEvent`` instance (or ``None`` if
519
+ no difference between ``old_values`` and ``new_values``)
520
+
521
+ .. deprecated:: 1.4
522
+ Use AuditService.make_audit_event_from_values() instead.
523
+ """
524
+ warnings.warn(
525
+ "AuditEvent.make_audit_event_from_values() is deprecated. "
526
+ "Use AuditService.make_audit_event_from_values() instead.",
527
+ DeprecationWarning,
528
+ stacklevel=2
529
+ )
530
+ from .services import get_audit_service
531
+ service = get_audit_service()
532
+ return service.make_audit_event_from_values(
533
+ old_values, new_values, object_pk, object_cls, request
534
+ )
535
+
536
+ @classmethod
537
+ def create_audit_event(cls, object_pk, object_cls, delta, is_create,
538
+ is_delete, request):
539
+ """.. deprecated:: 1.4
540
+ Use AuditService.create_audit_event() instead.
541
+ """
542
+ warnings.warn(
543
+ "AuditEvent.create_audit_event() is deprecated. "
544
+ "Use AuditService.create_audit_event() instead.",
545
+ DeprecationWarning,
546
+ stacklevel=2
547
+ )
548
+ from .services import get_audit_service
549
+ service = get_audit_service()
550
+ return service.create_audit_event(
551
+ object_pk, object_cls, delta, is_create, is_delete, request
552
+ )
553
+
554
+ @classmethod
555
+ def bootstrap_existing_model_records(cls, model_class, field_names,
556
+ batch_size=BOOTSTRAP_BATCH_SIZE,
557
+ iter_records=None):
558
+ """Creates audit events for all existing records of ``model_class``.
559
+ Database records are fetched and created in batched bulk operations
560
+ for efficiency.
561
+
562
+ :param model_class: a subclass of ``django.db.models.Model`` that uses
563
+ the ``audit_fields()`` decorator.
564
+ :param field_names: a collection of field names to include in the
565
+ resulting audit event ``delta`` value.
566
+ :param batch_size: (optional) create bootstrap records in batches of
567
+ ``batch_size``. Default: ``field_audit.const.BOOTSTRAP_BATCH_SIZE``.
568
+ Use ``None`` to disable batching.
569
+ :param iter_records: a callable used to fetch model instances.
570
+ If ``None`` (the default), ``.all().iterator()`` is called on the
571
+ model's default manager.
572
+ :returns: number of bootstrap records created
573
+
574
+ .. deprecated:: 1.4
575
+ Use AuditService.bootstrap_existing_model_records() instead.
576
+ """
577
+ warnings.warn(
578
+ "AuditEvent.bootstrap_existing_model_records() is deprecated. "
579
+ "Use AuditService.bootstrap_existing_model_records() instead.",
580
+ DeprecationWarning,
581
+ stacklevel=2
582
+ )
583
+ from .services import get_audit_service
584
+ service = get_audit_service()
585
+ return service.bootstrap_existing_model_records(
586
+ model_class, field_names, batch_size, iter_records
587
+ )
588
+
589
+ @classmethod
590
+ def bootstrap_top_up(cls, model_class, field_names,
591
+ batch_size=BOOTSTRAP_BATCH_SIZE):
592
+ """Creates audit events for existing records of ``model_class`` which
593
+ were created prior to auditing being enabled and are lacking a bootstrap
594
+ or create AuditEvent record.
595
+
596
+ :param model_class: see ``bootstrap_existing_model_records``
597
+ :param field_names: see ``bootstrap_existing_model_records``
598
+ :param batch_size: see ``bootstrap_existing_model_records``
599
+ (default=field_audit.const.BOOTSTRAP_BATCH_SIZE)
600
+ :returns: number of bootstrap records created
601
+
602
+ .. deprecated:: 1.4
603
+ Use AuditService.bootstrap_top_up() instead.
604
+ """
605
+ warnings.warn(
606
+ "AuditEvent.bootstrap_top_up() is deprecated. "
607
+ "Use AuditService.bootstrap_top_up() instead.",
608
+ DeprecationWarning,
609
+ stacklevel=2
610
+ )
611
+ from .services import get_audit_service
612
+ service = get_audit_service()
613
+ return service.bootstrap_top_up(model_class, field_names, batch_size)
614
+
615
+ @classmethod
616
+ def _change_context_db_value(cls, value):
617
+ return {} if value is None else value
618
+
619
+ def __repr__(self): # pragma: no cover
620
+ cls_name = type(self).__name__
621
+ return f"<{cls_name} ({self.id}, {self.object_class_path!r})>"
622
+
623
+
624
+ class AttachValuesError(Exception):
625
+ """Attaching initial values to a Model instance failed."""
626
+
627
+
628
+ class InvalidAuditActionError(Exception):
629
+ """A special QuerySet write method was called with non-AuditAction enum."""
630
+
631
+
632
+ class UnsetAuditActionError(Exception):
633
+ """A special QuerySet write method was called without an audit action."""
634
+
635
+
636
+ class AuditAction(Enum):
637
+
638
+ AUDIT = object()
639
+ IGNORE = object()
640
+ RAISE = object()
641
+
642
+ def __repr__(self): # pragma: no cover
643
+ return f"<{type(self).__name__}.{self.name}>"
644
+
645
+
646
+ def validate_audit_action(func):
647
+ """Decorator that performs validation on the ``audit_action`` keyword arg.
648
+
649
+ :raises: ``InvalidAuditActionError`` or ``UnsetAuditActionError``
650
+ """
651
+ @wraps(func)
652
+ def wrapper(self, *args, **kw):
653
+ try:
654
+ audit_action = kw["audit_action"]
655
+ except KeyError:
656
+ raise UnsetAuditActionError(
657
+ f"{type(self).__name__}.{func.__name__}() requires an audit "
658
+ "action as a keyword argument."
659
+ )
660
+ if audit_action not in AuditAction:
661
+ raise InvalidAuditActionError(
662
+ "The 'audit_action' argument must be a value of 'AuditAction', "
663
+ f"got {type(audit_action)!r}"
664
+ )
665
+ if audit_action is AuditAction.RAISE:
666
+ raise UnsetAuditActionError(
667
+ f"{type(self).__name__}.{func.__name__}() requires an audit "
668
+ "action"
669
+ )
670
+ return func(self, *args, **kw)
671
+ return wrapper
672
+
673
+
674
+ class AuditingQuerySet(models.QuerySet):
675
+ """A QuerySet that can perform field change auditing for bulk write methods.
676
+
677
+ When decorating a model class with
678
+ ``@audit_fields(..., audit_special_queryset_writes=True)``, the model's
679
+ default manager must be a subclass of ``AuditingManager``. Doing so
680
+ provides the required extra auditing logic for the following methods:
681
+
682
+ - ``bulk_create()``
683
+ - ``bulk_update()``
684
+ - ``delete()``
685
+ - ``update()``
686
+
687
+ Each of these methods has an additional required keyword argument
688
+ ``audit_action`` which defaults to ``AuditAction.RAISE``. When calling one
689
+ of the above methods, the value must be explicitly set to one of:
690
+
691
+ - ``AuditAction.AUDIT`` -- perform the database write, creating audit events
692
+ for the changes.
693
+ - ``AuditAction.IGNORE`` -- perform the database write without performing
694
+ any auditing logic (pass through).
695
+
696
+ Calling one of these methods without setting the desired audit action will
697
+ raise an exception.
698
+ """
699
+
700
+ @validate_audit_action
701
+ def bulk_create(self, objs, *, audit_action=AuditAction.RAISE, **kw):
702
+ if audit_action is AuditAction.IGNORE or not objs:
703
+ return super().bulk_create(objs, **kw)
704
+ assert audit_action is AuditAction.AUDIT, audit_action
705
+
706
+ from .field_audit import request
707
+ request = request.get()
708
+
709
+ with transaction.atomic(using=self.db):
710
+ created_objs = super().bulk_create(objs, **kw)
711
+ audit_events = []
712
+ from .services import get_audit_service
713
+ service = get_audit_service()
714
+ for obj in created_objs:
715
+ audit_events.append(
716
+ service.make_audit_event_from_instance(
717
+ obj, True, False, request))
718
+ AuditEvent.objects.bulk_create(audit_events)
719
+ return created_objs
720
+
721
+ @validate_audit_action
722
+ def bulk_update(self, *args, audit_action=AuditAction.RAISE, **kw):
723
+ if audit_action is AuditAction.IGNORE:
724
+ return super().bulk_update(*args, **kw)
725
+ else:
726
+ raise NotImplementedError(
727
+ "Change auditing is not implemented for bulk_update()."
728
+ )
729
+
730
+ @validate_audit_action
731
+ def delete(self, *, audit_action=AuditAction.RAISE):
732
+ if audit_action is AuditAction.IGNORE:
733
+ return super().delete()
734
+ assert audit_action is AuditAction.AUDIT, audit_action
735
+ from .field_audit import request
736
+ from .services import get_audit_service
737
+ service = get_audit_service()
738
+ request = request.get()
739
+ audit_events = []
740
+ fields_to_fetch = set(service.get_field_names(self.model)) | {'pk'}
741
+ current_values = {}
742
+ for values_for_instance in self.values(*fields_to_fetch):
743
+ pk = values_for_instance.pop('pk')
744
+ current_values[pk] = values_for_instance
745
+
746
+ for pk, current_values_for_pk in current_values.items():
747
+ audit_event = service.make_audit_event_from_values(
748
+ current_values_for_pk,
749
+ {},
750
+ pk,
751
+ self.model,
752
+ request
753
+ )
754
+ audit_events.append(audit_event)
755
+
756
+ with transaction.atomic(using=self.db):
757
+ value = super().delete()
758
+ if audit_events:
759
+ # write the audit events _after_ the delete succeeds
760
+ AuditEvent.objects.bulk_create(audit_events)
761
+ return value
762
+
763
+ @validate_audit_action
764
+ def update(self, *, audit_action=AuditAction.RAISE, **kw):
765
+ """
766
+ In order to determine the old and new values of the records matched by
767
+ the queryset, a fetch of audited values for the matched records is
768
+ performed, resulting in one fetch of the current values, one update of
769
+ the matched records, and one bulk creation of audit events.
770
+
771
+ NOTE: if django.db.models.Expressions are provided as update arguments,
772
+ an additional fetch of records is performed to obtain new values.
773
+ """
774
+ if audit_action is AuditAction.IGNORE:
775
+ return super().update(**kw)
776
+ assert audit_action is AuditAction.AUDIT, audit_action
777
+
778
+ from .services import get_audit_service
779
+ service = get_audit_service()
780
+ fields_to_update = set(kw.keys())
781
+ audited_fields = set(service.get_field_names(self.model))
782
+ fields_to_audit = fields_to_update & audited_fields
783
+ if not fields_to_audit:
784
+ # no audited fields are changing
785
+ return super().update(**kw)
786
+
787
+ new_values = {field: kw[field] for field in fields_to_audit}
788
+ uses_expressions = any(
789
+ [isinstance(val, Expression) for val in new_values.values()])
790
+
791
+ old_values = {}
792
+ values_to_fetch = fields_to_update | {"pk"}
793
+ for value in self.values(*values_to_fetch):
794
+ pk = value.pop('pk')
795
+ old_values[pk] = value
796
+
797
+ with transaction.atomic(using=self.db):
798
+ rows = super().update(**kw)
799
+ if uses_expressions:
800
+ # fetch updated values to ensure audit event deltas are accurate
801
+ # after update is performed with expressions
802
+ new_values = {}
803
+ for value in self.values(*values_to_fetch):
804
+ pk = value.pop('pk')
805
+ new_values[pk] = value
806
+ else:
807
+ new_values = {pk: new_values for pk in old_values.keys()}
808
+
809
+ # create and write the audit events _after_ the update succeeds
810
+ from .field_audit import request
811
+ request = request.get()
812
+ audit_events = []
813
+ for pk, old_values_for_pk in old_values.items():
814
+ audit_event = service.make_audit_event_from_values(
815
+ old_values_for_pk, new_values[pk], pk, self.model, request
816
+ )
817
+ if audit_event:
818
+ audit_events.append(audit_event)
819
+ if audit_events:
820
+ AuditEvent.objects.bulk_create(audit_events)
821
+ return rows
822
+
823
+
824
+ AuditingManager = models.Manager.from_queryset(AuditingQuerySet)