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/services.py
ADDED
|
@@ -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)
|