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.
- django_field_audit-1.4.0.dist-info/METADATA +362 -0
- django_field_audit-1.4.0.dist-info/RECORD +20 -0
- django_field_audit-1.4.0.dist-info/WHEEL +4 -0
- django_field_audit-1.4.0.dist-info/licenses/LICENSE +24 -0
- field_audit/__init__.py +4 -0
- field_audit/apps.py +11 -0
- field_audit/auditors.py +125 -0
- field_audit/const.py +19 -0
- field_audit/field_audit.py +255 -0
- field_audit/management/__init__.py +0 -0
- field_audit/management/commands/__init__.py +0 -0
- field_audit/management/commands/bootstrap_field_audit_events.py +127 -0
- field_audit/middleware.py +17 -0
- field_audit/migrations/0001_initial.py +37 -0
- field_audit/migrations/0002_add_is_bootstrap_column.py +31 -0
- field_audit/migrations/0003_alter_auditevent_change_context_and_more.py +32 -0
- field_audit/migrations/__init__.py +0 -0
- field_audit/models.py +824 -0
- field_audit/services.py +378 -0
- field_audit/utils.py +91 -0
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)
|