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.

@@ -0,0 +1,378 @@
1
+ from itertools import islice
2
+
3
+ from django.db import models
4
+
5
+ from .const import BOOTSTRAP_BATCH_SIZE
6
+
7
+
8
+ class AuditService:
9
+ """Service class containing the core audit logic extracted from AuditEvent.
10
+
11
+ This class can be subclassed to provide custom audit implementations while
12
+ maintaining backward compatibility with the existing AuditEvent API.
13
+ """
14
+
15
+ ATTACH_FIELD_NAMES_AT = "__field_audit_field_names"
16
+ ATTACH_INIT_VALUES_AT = "__field_audit_init_values"
17
+ ATTACH_INIT_M2M_VALUES_AT = "__field_audit_init_m2m_values"
18
+
19
+ def attach_field_names(self, model_class, field_names):
20
+ """Attaches a collection of field names to a Model class for auditing.
21
+
22
+ :param model_class: a Django Model class under audit
23
+ :param field_names: collection of field names to audit on the model
24
+ """
25
+ setattr(model_class, self.ATTACH_FIELD_NAMES_AT, field_names)
26
+
27
+ def get_field_names(self, model_class):
28
+ """Returns the audit field names stored on the audited Model class
29
+
30
+ :param model_class: a Django Model class under audit
31
+ """
32
+ return getattr(model_class, self.ATTACH_FIELD_NAMES_AT)
33
+
34
+ def get_field_value(self, instance, field_name, bootstrap=False):
35
+ """Returns the database value of a field on ``instance``.
36
+
37
+ :param instance: an instance of a Django model
38
+ :param field_name: name of a field on ``instance``
39
+ """
40
+ field = instance._meta.get_field(field_name)
41
+
42
+ if isinstance(field, models.ManyToManyField):
43
+ # ManyToManyField handled by Django signals
44
+ if bootstrap:
45
+ return self.get_m2m_field_value(instance, field_name)
46
+ return []
47
+ return field.to_python(field.value_from_object(instance))
48
+
49
+ def attach_initial_values(self, instance):
50
+ """Save copies of field values on an instance so they can be used later
51
+ to determine if the instance has changed and record what the previous
52
+ values were.
53
+
54
+ :param instance: instance of a Model subclass to be audited for changes
55
+ :raises: ``AttachValuesError`` if initial values are already attached to
56
+ the instance
57
+ """
58
+ from .models import AttachValuesError
59
+
60
+ if hasattr(instance, self.ATTACH_INIT_VALUES_AT):
61
+ # This should never happen, but to be safe, refuse to clobber
62
+ # existing attributes.
63
+ raise AttachValuesError(
64
+ f"refusing to overwrite {self.ATTACH_INIT_VALUES_AT!r} "
65
+ f"on model instance: {instance}"
66
+ )
67
+ field_names = self.get_field_names(instance)
68
+ init_values = {f: self.get_field_value(instance, f) for f in field_names}
69
+ setattr(instance, self.ATTACH_INIT_VALUES_AT, init_values)
70
+
71
+ def attach_initial_m2m_values(self, instance, field_name):
72
+ field = instance._meta.get_field(field_name)
73
+ if not isinstance(field, models.ManyToManyField):
74
+ return None
75
+
76
+ values = self.get_m2m_field_value(instance, field_name)
77
+ init_values = getattr(
78
+ instance, self.ATTACH_INIT_M2M_VALUES_AT, None
79
+ ) or {}
80
+ init_values.update({field_name: values})
81
+ setattr(instance, self.ATTACH_INIT_M2M_VALUES_AT, init_values)
82
+
83
+ def get_initial_m2m_values(self, instance, field_name):
84
+ init_values = getattr(
85
+ instance, self.ATTACH_INIT_M2M_VALUES_AT, None
86
+ ) or {}
87
+ return init_values.get(field_name)
88
+
89
+ def clear_initial_m2m_field_values(self, instance, field_name):
90
+ init_values = getattr(
91
+ instance, self.ATTACH_INIT_M2M_VALUES_AT, None
92
+ ) or {}
93
+ init_values.pop(field_name, None)
94
+ setattr(instance, self.ATTACH_INIT_M2M_VALUES_AT, init_values)
95
+
96
+ def get_m2m_field_value(self, instance, field_name):
97
+ if instance.pk is None:
98
+ # Instance is not saved, return empty list
99
+ return []
100
+ else:
101
+ # Instance is saved, we can access the related objects
102
+ related_manager = getattr(instance, field_name)
103
+ return list(related_manager.values_list('pk', flat=True))
104
+
105
+ def reset_initial_values(self, instance):
106
+ """Returns the previously attached "initial values" and attaches new
107
+ values.
108
+
109
+ :param instance: instance of a Model subclass to be audited for changes
110
+ :raises: ``AttachValuesError`` if initial values are not attached to
111
+ the instance
112
+ """
113
+ from .models import AttachValuesError
114
+
115
+ try:
116
+ values = getattr(instance, self.ATTACH_INIT_VALUES_AT)
117
+ except AttributeError:
118
+ raise AttachValuesError("cannot reset values that were never set")
119
+ delattr(instance, self.ATTACH_INIT_VALUES_AT)
120
+ self.attach_initial_values(instance)
121
+ return values
122
+
123
+ def audit_field_changes(self, *args, **kw):
124
+ """Convenience method that calls ``make_audit_event_from_instance()``
125
+ and saves the event (if one is returned).
126
+
127
+ All [keyword] arguments are passed directly to
128
+ ``make_audit_event_from_instance()``, see that method for usage.
129
+ """
130
+ event = self.make_audit_event_from_instance(*args, **kw)
131
+ if event is not None:
132
+ event.save()
133
+
134
+ def get_delta_from_instance(self, instance, is_create, is_delete):
135
+ """
136
+ Returns a dictionary representing the delta of an instance of a model
137
+ being audited for changes.
138
+ Has the side effect of calling reset_initial_values(instance)
139
+ which grabs and updates the initial values stored on the instance.
140
+
141
+ :param instance: instance of a Model subclass to be audited for changes
142
+ :param is_create: whether or not the audited event creates a new DB
143
+ record (setting ``True`` implies that ``instance`` is changing)
144
+ :param is_delete: whether or not the audited event deletes an existing
145
+ DB record (setting ``True`` implies that ``instance`` is changing)
146
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
147
+ :raises: ``AssertionError`` if both is_create and is_delete are true
148
+ """
149
+ assert not (is_create and is_delete), \
150
+ "is_create and is_delete cannot both be true"
151
+ fields_to_audit = self.get_field_names(instance)
152
+ # SIDE EFFECT: fetch and reset initial values for next db write
153
+ init_values = self.reset_initial_values(instance)
154
+ old_values = {} if is_create else init_values
155
+ new_values = {} if is_delete else \
156
+ {f: self.get_field_value(instance, f) for f in fields_to_audit}
157
+ return self.create_delta(old_values, new_values)
158
+
159
+ def create_delta(self, old_values, new_values):
160
+ """
161
+ Compares two dictionaries and creates a delta between the two
162
+
163
+ :param old_values: {field_name: field_value, ...} representing the
164
+ values prior to a change
165
+ :param new_values: {field_name: field_value, ...} representing the
166
+ values after a change
167
+ :returns: {field_name: {'old': old_value, 'new': new_value}, ...}
168
+ :raises: ``AssertionError`` if both old_values and new_values are empty
169
+ do not match
170
+ """
171
+ assert old_values or new_values, \
172
+ "Must provide a non-empty value for either old_values or new_values"
173
+
174
+ changed_fields = old_values.keys() if old_values else new_values.keys()
175
+ if old_values and new_values:
176
+ changed_fields = new_values.keys()
177
+
178
+ delta = {}
179
+ for field_name in changed_fields:
180
+ if not old_values:
181
+ delta[field_name] = {"new": new_values[field_name]}
182
+ elif not new_values:
183
+ delta[field_name] = {"old": old_values[field_name]}
184
+ else:
185
+ try:
186
+ old_value = old_values[field_name]
187
+ except KeyError:
188
+ delta[field_name] = {"new": new_values[field_name]}
189
+ else:
190
+ if old_value != new_values[field_name]:
191
+ delta[field_name] = {"old": old_value,
192
+ "new": new_values[field_name]}
193
+ return delta
194
+
195
+ def make_audit_event_from_instance(self, instance, is_create, is_delete,
196
+ request, object_pk=None):
197
+ """Factory method for creating a new ``AuditEvent`` for an instance of a
198
+ model that's being audited for changes.
199
+
200
+ :param instance: instance of a Model subclass to be audited for changes
201
+ :param is_create: whether or not the audited event creates a new DB
202
+ record (setting ``True`` implies that ``instance`` is changing)
203
+ :param is_delete: whether or not the audited event deletes an existing
204
+ DB record (setting ``True`` implies that ``instance`` is changing)
205
+ :param request: the request object responsible for the change (or
206
+ ``None`` if there is no request)
207
+ :param object_pk: (Optional) primary key of the instance. Only used when
208
+ ``is_delete == True``, that is, when the instance itself no longer
209
+ references its pre-delete primary key. It is ambiguous to set this
210
+ when ``is_delete == False``, and doing so will raise an exception.
211
+ :returns: an unsaved ``AuditEvent`` instance (or ``None`` if
212
+ ``instance`` has not changed)
213
+ :raises: ``ValueError`` on invalid use of the ``object_pk`` argument
214
+ """
215
+ if not is_delete:
216
+ if object_pk is not None:
217
+ raise ValueError(
218
+ "'object_pk' arg is ambiguous when 'is_delete == False'"
219
+ )
220
+ object_pk = instance.pk
221
+
222
+ delta = self.get_delta_from_instance(instance, is_create, is_delete)
223
+ if delta:
224
+ return self.create_audit_event(object_pk, type(instance), delta,
225
+ is_create, is_delete, request)
226
+
227
+ def make_audit_event_from_values(self, old_values, new_values, object_pk,
228
+ object_cls, request):
229
+ """Factory method for creating a new ``AuditEvent`` based on old and new
230
+ values.
231
+
232
+ :param old_values: {field_name: field_value, ...} representing the
233
+ values prior to a change
234
+ :param new_values: {field_name: field_value, ...} representing the
235
+ values after a change
236
+ :param object_pk: primary key of the instance
237
+ :param object_cls: class type of the object being audited
238
+ :param request: the request object responsible for the change (or
239
+ ``None`` if there is no request)
240
+ :returns: an unsaved ``AuditEvent`` instance (or ``None`` if
241
+ no difference between ``old_values`` and ``new_values``)
242
+ """
243
+ is_create = not old_values
244
+ is_delete = not new_values
245
+ delta = self.create_delta(old_values, new_values)
246
+ if delta:
247
+ return self.create_audit_event(object_pk, object_cls, delta,
248
+ is_create, is_delete, request)
249
+
250
+ def create_audit_event(self, object_pk, object_cls, delta, is_create,
251
+ is_delete, request):
252
+ from .auditors import audit_dispatcher
253
+ from .field_audit import get_audited_class_path
254
+ from .models import AuditEvent
255
+ change_context = audit_dispatcher.dispatch(request)
256
+ object_cls_path = get_audited_class_path(object_cls)
257
+ return AuditEvent(
258
+ object_class_path=object_cls_path,
259
+ object_pk=object_pk,
260
+ change_context=self._change_context_db_value(change_context),
261
+ is_create=is_create,
262
+ is_delete=is_delete,
263
+ delta=delta,
264
+ )
265
+
266
+ def bootstrap_existing_model_records(self, model_class, field_names,
267
+ batch_size=BOOTSTRAP_BATCH_SIZE,
268
+ iter_records=None):
269
+ """Creates audit events for all existing records of ``model_class``.
270
+ Database records are fetched and created in batched bulk operations
271
+ for efficiency.
272
+
273
+ :param model_class: a subclass of ``django.db.models.Model`` that uses
274
+ the ``audit_fields()`` decorator.
275
+ :param field_names: a collection of field names to include in the
276
+ resulting audit event ``delta`` value.
277
+ :param batch_size: (optional) create bootstrap records in batches of
278
+ ``batch_size``. Default: ``field_audit.const.BOOTSTRAP_BATCH_SIZE``.
279
+ Use ``None`` to disable batching.
280
+ :param iter_records: a callable used to fetch model instances.
281
+ If ``None`` (the default), ``.all().iterator()`` is called on the
282
+ model's default manager.
283
+ :returns: number of bootstrap records created
284
+ """
285
+ from .auditors import audit_dispatcher
286
+ from .field_audit import get_audited_class_path
287
+ from .models import AuditEvent
288
+
289
+ if iter_records is None:
290
+ iter_records = model_class._default_manager.all().iterator
291
+
292
+ def iter_events():
293
+ for instance in iter_records():
294
+ delta = {}
295
+ for field_name in field_names:
296
+ value = self.get_field_value(
297
+ instance, field_name, bootstrap=True
298
+ )
299
+ delta[field_name] = {"new": value}
300
+ yield AuditEvent(
301
+ object_class_path=object_class_path,
302
+ object_pk=instance.pk,
303
+ change_context=change_context,
304
+ is_bootstrap=True,
305
+ delta=delta,
306
+ )
307
+
308
+ change_context = self._change_context_db_value(
309
+ audit_dispatcher.dispatch(None)
310
+ )
311
+ object_class_path = get_audited_class_path(model_class)
312
+
313
+ if batch_size is None:
314
+ return len(AuditEvent.objects.bulk_create(iter_events()))
315
+ # bulk_create in batches efficiently
316
+ # see: https://docs.djangoproject.com/en/4.0/ref/models/querysets/#bulk-create # noqa: E501
317
+ events = iter_events()
318
+ total = 0
319
+ while True:
320
+ batch = list(islice(events, batch_size))
321
+ if not batch:
322
+ break
323
+ total += len(batch)
324
+ AuditEvent.objects.bulk_create(batch, batch_size=batch_size)
325
+ return total
326
+
327
+ def bootstrap_top_up(self, model_class, field_names,
328
+ batch_size=BOOTSTRAP_BATCH_SIZE):
329
+ """Creates audit events for existing records of ``model_class`` which
330
+ were created prior to auditing being enabled and are lacking a bootstrap
331
+ or create AuditEvent record.
332
+
333
+ :param model_class: see ``bootstrap_existing_model_records``
334
+ :param field_names: see ``bootstrap_existing_model_records``
335
+ :param batch_size: see ``bootstrap_existing_model_records``
336
+ (default=field_audit.const.BOOTSTRAP_BATCH_SIZE)
337
+ :returns: number of bootstrap records created
338
+ """
339
+ from .models import AuditEvent
340
+
341
+ subquery = (
342
+ AuditEvent.objects
343
+ .cast_object_pks_list(model_class)
344
+ .filter(
345
+ models.Q(models.Q(is_bootstrap=True) | models.Q(is_create=True))
346
+ )
347
+ )
348
+ # bootstrap the model records who do not match the subquery
349
+ model_manager = model_class._default_manager
350
+ return self.bootstrap_existing_model_records(
351
+ model_class,
352
+ field_names,
353
+ batch_size=batch_size,
354
+ iter_records=model_manager.exclude(pk__in=subquery).iterator,
355
+ )
356
+
357
+ def _change_context_db_value(self, value):
358
+ return {} if value is None else value
359
+
360
+
361
+ def get_audit_service():
362
+ """Returns the configured audit service instance.
363
+
364
+ This can be overridden in settings by setting FIELD_AUDIT_SERVICE_CLASS.
365
+ """
366
+ from django.conf import settings
367
+ from .utils import class_import_helper
368
+
369
+ settings_attr = "FIELD_AUDIT_SERVICE_CLASS"
370
+ try:
371
+ class_path = getattr(settings, settings_attr)
372
+ service_class = class_import_helper(
373
+ class_path, f"{settings_attr!r} value", AuditService
374
+ )
375
+ except AttributeError:
376
+ service_class = AuditService
377
+
378
+ return service_class()
field_audit/utils.py ADDED
@@ -0,0 +1,91 @@
1
+ import logging
2
+
3
+ from django.core.exceptions import ImproperlyConfigured
4
+ from django.db.migrations import RunPython
5
+ from django.utils.module_loading import import_string
6
+
7
+ from .const import BOOTSTRAP_BATCH_SIZE
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ def class_import_helper(dotted_path, item_description, require_type=None):
13
+ """Returns an imported class described by ``dotted_path``.
14
+
15
+ Similar to ``django.db.models.options.Options._get_default_pk_class()``.
16
+ It would be nice if Django had a helper function for doing this.
17
+
18
+ :param dotted_path: Python syntax "dotted path" of class to be imported.
19
+ :param item_description: Description for generating helpful exception
20
+ messages.
21
+ :param required_type: (Optional) require that the imported class is a
22
+ subclass of this.
23
+ :raises: ImproperlyConfigured, ValueError
24
+ """
25
+ if not isinstance(dotted_path, str):
26
+ # Django should implement this check to avoid confusing errors
27
+ # from `import_string()`, like:
28
+ # AttributeError: type object 'AutoField' has no attribute 'rsplit'
29
+ raise ValueError(
30
+ f"invalid {item_description}: expected 'str', "
31
+ f"got {dotted_path!r}"
32
+ )
33
+
34
+ try:
35
+ class_ = import_string(dotted_path)
36
+ except ImportError as exc:
37
+ msg = f"failed to import {item_description}: {dotted_path!r}"
38
+ raise ImproperlyConfigured(msg) from exc
39
+
40
+ if require_type is not None:
41
+ if not issubclass(class_, require_type):
42
+ raise ValueError(
43
+ f"invalid imported {item_description}: expected subclass of "
44
+ f"{require_type.__name__!r}, got {class_!r}"
45
+ )
46
+ return class_
47
+
48
+
49
+ def get_fqcn(cls):
50
+ """Get the full dot-delimited class path (module + qualname)
51
+
52
+ See: https://stackoverflow.com/a/2020083
53
+ """
54
+ return f"{cls.__module__}.{cls.__qualname__}"
55
+
56
+
57
+ def run_bootstrap(model_class, field_names, batch_size=BOOTSTRAP_BATCH_SIZE,
58
+ iter_records=None, reverse_func=RunPython.noop):
59
+ """Returns a django migration Operation which calls
60
+ ``AuditEvent.bootstrap_existing_model_records()`` to add "migration" records
61
+ for existing model records.
62
+
63
+ :param model_class: see
64
+ ``field_audit.models.AuditEvent.bootstrap_existing_model_records``
65
+ :param field_names: see
66
+ ``field_audit.models.AuditEvent.bootstrap_existing_model_records``
67
+ :param batch_size: see
68
+ ``field_audit.models.AuditEvent.bootstrap_existing_model_records``,
69
+ (default=field_audit.const.BOOTSTRAP_BATCH_SIZE)
70
+ :param iter_records: see
71
+ ``field_audit.models.AuditEvent.bootstrap_existing_model_records``
72
+ :param reverse_func: (optional, default: ``RunPython.noop``) a callable for
73
+ unapplying the migration. Passed directly to the returned
74
+ ``RunPython()`` instance as the ``reverse_code`` argument.
75
+ """
76
+ def do_bootstrap(*args, **kwargs):
77
+ from .services import get_audit_service
78
+
79
+ service = get_audit_service()
80
+ count = service.bootstrap_existing_model_records(
81
+ model_class,
82
+ field_names,
83
+ batch_size,
84
+ iter_records,
85
+ )
86
+ log.info(
87
+ f"bootstrapped {count} audit event{'' if count == 1 else 's'} for: "
88
+ f"{model_class._meta.app_label}.{model_class._meta.object_name}"
89
+ )
90
+
91
+ return RunPython(do_bootstrap, reverse_code=reverse_func)