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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
from functools import wraps
|
|
3
|
+
|
|
4
|
+
from django.db import models, router, transaction
|
|
5
|
+
from django.db.models.signals import m2m_changed
|
|
6
|
+
|
|
7
|
+
from .utils import get_fqcn
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AlreadyAudited",
|
|
11
|
+
"audit_fields",
|
|
12
|
+
"get_audited_class_path",
|
|
13
|
+
"get_audited_models",
|
|
14
|
+
"request",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AlreadyAudited(Exception):
|
|
19
|
+
"""Model class is already audited."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidManagerError(Exception):
|
|
23
|
+
"""Model class has an invalid manager."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def audit_fields(*field_names, class_path=None, audit_special_queryset_writes=False): # noqa: E501
|
|
27
|
+
"""Class decorator for auditing field changes on DB model instances.
|
|
28
|
+
|
|
29
|
+
Use this on Model subclasses which need field change auditing.
|
|
30
|
+
|
|
31
|
+
:param field_names: names of fields on the model that need to be audited
|
|
32
|
+
:param class_path: optional name to use as the ``object_class_path`` field
|
|
33
|
+
on audit events. The default (``None``) means the audited model's
|
|
34
|
+
fully qualified "dot path" will be used.
|
|
35
|
+
:param audit_special_queryset_writes: optional bool (default ``False``)
|
|
36
|
+
enables auditing of the "special" QuerySet write methods (see below) for
|
|
37
|
+
this model. Setting this to ``True`` requires the decorated model's
|
|
38
|
+
default manager be an instance of ``field_audit.models.AuditingManager``
|
|
39
|
+
**IMPORTANT**: These special QuerySet write methods often perform bulk
|
|
40
|
+
or batched operations, and auditing their changes may impact efficiency.
|
|
41
|
+
:raises: ``ValueError``, ``InvalidManagerError``
|
|
42
|
+
|
|
43
|
+
Auditing is performed by decorating the model class's ``__init__()``,
|
|
44
|
+
``delete()`` and ``save()`` methods which provides audit events for all DB
|
|
45
|
+
write operations except the "special" QuerySet write methods:
|
|
46
|
+
|
|
47
|
+
- ``QuerySet.bulk_create()``
|
|
48
|
+
- ``QuerySet.bulk_update()``
|
|
49
|
+
- ``QuerySet.update()``
|
|
50
|
+
- ``QuerySet.delete()``
|
|
51
|
+
|
|
52
|
+
Using ``audit_special_queryset_writes=True`` (with the custom manager) lifts
|
|
53
|
+
this limitation.
|
|
54
|
+
"""
|
|
55
|
+
def wrapper(cls):
|
|
56
|
+
if cls in _audited_models:
|
|
57
|
+
raise AlreadyAudited(cls)
|
|
58
|
+
if not issubclass(cls, models.Model):
|
|
59
|
+
raise ValueError(f"expected Model subclass, got: {cls}")
|
|
60
|
+
service.attach_field_names(cls, field_names)
|
|
61
|
+
if audit_special_queryset_writes:
|
|
62
|
+
_verify_auditing_manager(cls)
|
|
63
|
+
cls.__init__ = _decorate_init(cls.__init__)
|
|
64
|
+
cls.save = _decorate_db_write(cls.save)
|
|
65
|
+
cls.delete = _decorate_db_write(cls.delete)
|
|
66
|
+
cls.refresh_from_db = _decorate_refresh_from_db(cls.refresh_from_db)
|
|
67
|
+
|
|
68
|
+
_register_m2m_signals(cls, field_names)
|
|
69
|
+
_audited_models[cls] = get_fqcn(cls) if class_path is None else class_path # noqa: E501
|
|
70
|
+
return cls
|
|
71
|
+
if not field_names:
|
|
72
|
+
raise ValueError("at least one field name is required")
|
|
73
|
+
from .services import get_audit_service
|
|
74
|
+
service = get_audit_service()
|
|
75
|
+
return wrapper
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _verify_auditing_manager(cls):
|
|
79
|
+
"""Verifies a model class is configured with an appropriate manager for
|
|
80
|
+
special QuerySet write auditing.
|
|
81
|
+
|
|
82
|
+
:param cls: a Model subclass
|
|
83
|
+
:raises: ``InvalidManagerError``
|
|
84
|
+
"""
|
|
85
|
+
from .models import AuditingManager
|
|
86
|
+
# Don't assume 'cls.objects', use 'cls._default_manager' instead.
|
|
87
|
+
# see: https://docs.djangoproject.com/en/4.0/topics/db/managers/#default-managers # noqa: E501
|
|
88
|
+
if not isinstance(cls._default_manager, AuditingManager):
|
|
89
|
+
raise InvalidManagerError(
|
|
90
|
+
"QuerySet write auditing requires an AuditingManager, got "
|
|
91
|
+
f"{type(cls._default_manager)}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _decorate_init(init):
|
|
96
|
+
"""Decorates the "initialization" (e.g. __init__) method on Model
|
|
97
|
+
subclasses. Responsible for ensuring that the initial field values are
|
|
98
|
+
recorded in order to generate an audit event change delta later.
|
|
99
|
+
|
|
100
|
+
:param init: the method to decorate
|
|
101
|
+
"""
|
|
102
|
+
@wraps(init)
|
|
103
|
+
def wrapper(self, *args, **kw):
|
|
104
|
+
init(self, *args, **kw)
|
|
105
|
+
service.attach_initial_values(self)
|
|
106
|
+
from .services import get_audit_service
|
|
107
|
+
service = get_audit_service()
|
|
108
|
+
return wrapper
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _decorate_db_write(func):
|
|
112
|
+
"""Decorates the "database write" methods (e.g. save, delete) on Model
|
|
113
|
+
subclasses. Responsible for creating an audit event when a model instance
|
|
114
|
+
changes.
|
|
115
|
+
|
|
116
|
+
:param func: the "db write" method to decorate
|
|
117
|
+
"""
|
|
118
|
+
@wraps(func)
|
|
119
|
+
def wrapper(self, *args, **kw):
|
|
120
|
+
# for details on using 'self._state', see:
|
|
121
|
+
# - https://docs.djangoproject.com/en/dev/ref/models/instances/#state
|
|
122
|
+
# - https://stackoverflow.com/questions/907695/
|
|
123
|
+
is_create = is_save and self._state.adding
|
|
124
|
+
object_pk = self.pk if is_delete else None
|
|
125
|
+
|
|
126
|
+
db = router.db_for_write(type(self))
|
|
127
|
+
with transaction.atomic(using=db):
|
|
128
|
+
ret = func(self, *args, **kw)
|
|
129
|
+
service.audit_field_changes(
|
|
130
|
+
self,
|
|
131
|
+
is_create,
|
|
132
|
+
is_delete,
|
|
133
|
+
request.get(),
|
|
134
|
+
object_pk,
|
|
135
|
+
)
|
|
136
|
+
return ret
|
|
137
|
+
is_save = func.__name__ == "save"
|
|
138
|
+
is_delete = func.__name__ == "delete"
|
|
139
|
+
if not is_save and not is_delete:
|
|
140
|
+
raise ValueError(f"invalid function for decoration: {func}")
|
|
141
|
+
from .services import get_audit_service
|
|
142
|
+
service = get_audit_service()
|
|
143
|
+
return wrapper
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _decorate_refresh_from_db(func):
|
|
147
|
+
"""Decorates the "refresh from db" method on Model subclasses. This is
|
|
148
|
+
necessary to ensure that all audited fields are included in the refresh
|
|
149
|
+
to avoid recursively calling the refresh for deferred fields.
|
|
150
|
+
|
|
151
|
+
:param func: the "refresh from db" method to decorate
|
|
152
|
+
"""
|
|
153
|
+
@wraps(func)
|
|
154
|
+
def wrapper(self, using=None, fields=None, **kwargs):
|
|
155
|
+
if fields is not None:
|
|
156
|
+
fields = set(fields) | set(service.get_field_names(self))
|
|
157
|
+
func(self, using, fields, **kwargs)
|
|
158
|
+
|
|
159
|
+
from .services import get_audit_service
|
|
160
|
+
service = get_audit_service()
|
|
161
|
+
return wrapper
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _register_m2m_signals(cls, field_names):
|
|
165
|
+
"""Register m2m_changed signal handlers for ManyToManyFields.
|
|
166
|
+
|
|
167
|
+
:param cls: The model class being audited
|
|
168
|
+
:param field_names: List of field names that are being audited
|
|
169
|
+
"""
|
|
170
|
+
for field_name in field_names:
|
|
171
|
+
try:
|
|
172
|
+
field = cls._meta.get_field(field_name)
|
|
173
|
+
if isinstance(field, models.ManyToManyField):
|
|
174
|
+
m2m_changed.connect(
|
|
175
|
+
_m2m_changed_handler,
|
|
176
|
+
sender=field.remote_field.through,
|
|
177
|
+
weak=False
|
|
178
|
+
)
|
|
179
|
+
except Exception:
|
|
180
|
+
# If field doesn't exist or isn't a M2M field, continue
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _m2m_changed_handler(sender, instance, action, pk_set, **kwargs):
|
|
185
|
+
"""Signal handler for m2m_changed to audit ManyToManyField changes.
|
|
186
|
+
|
|
187
|
+
:param sender: The intermediate model class for the ManyToManyField
|
|
188
|
+
:param instance: The instance whose many-to-many relation is updated
|
|
189
|
+
:param action: A string indicating the type of update
|
|
190
|
+
:param pk_set: For add/remove actions, set of primary key values
|
|
191
|
+
"""
|
|
192
|
+
from .services import get_audit_service
|
|
193
|
+
|
|
194
|
+
service = get_audit_service()
|
|
195
|
+
|
|
196
|
+
if action not in ('post_add', 'post_remove', 'post_clear', 'pre_clear'):
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if type(instance) not in _audited_models:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# Find which M2M field this change relates to
|
|
203
|
+
m2m_field = None
|
|
204
|
+
field_name = None
|
|
205
|
+
for field in instance._meta.get_fields():
|
|
206
|
+
if (
|
|
207
|
+
isinstance(field, models.ManyToManyField) and
|
|
208
|
+
hasattr(field, 'remote_field') and
|
|
209
|
+
field.remote_field.through == sender
|
|
210
|
+
):
|
|
211
|
+
m2m_field = field
|
|
212
|
+
field_name = field.name
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
if not m2m_field or field_name not in service.get_field_names(instance):
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if action == 'pre_clear':
|
|
219
|
+
# `pk_set` not supplied for clear actions. Determine initial values
|
|
220
|
+
# in the `pre_clear` event
|
|
221
|
+
service.attach_initial_m2m_values(instance, field_name)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if action == 'post_clear':
|
|
225
|
+
initial_values = service.get_initial_m2m_values(instance, field_name)
|
|
226
|
+
if not initial_values:
|
|
227
|
+
return
|
|
228
|
+
delta = {field_name: {'remove': initial_values}}
|
|
229
|
+
else:
|
|
230
|
+
if not pk_set:
|
|
231
|
+
# the change was a no-op
|
|
232
|
+
return
|
|
233
|
+
delta_key = 'add' if action == 'post_add' else 'remove'
|
|
234
|
+
delta = {field_name: {delta_key: list(pk_set)}}
|
|
235
|
+
|
|
236
|
+
req = request.get()
|
|
237
|
+
event = service.create_audit_event(
|
|
238
|
+
instance.pk, instance.__class__, delta, False, False, req
|
|
239
|
+
)
|
|
240
|
+
if event is not None:
|
|
241
|
+
event.save()
|
|
242
|
+
|
|
243
|
+
service.clear_initial_m2m_field_values(instance, field_name)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_audited_models():
|
|
247
|
+
return _audited_models.copy()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_audited_class_path(cls):
|
|
251
|
+
return _audited_models[cls]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_audited_models = {}
|
|
255
|
+
request = contextvars.ContextVar("request", default=None)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
|
|
3
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
4
|
+
|
|
5
|
+
from field_audit.const import BOOTSTRAP_BATCH_SIZE
|
|
6
|
+
from field_audit.field_audit import get_audited_models
|
|
7
|
+
from field_audit.services import get_audit_service
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(BaseCommand):
|
|
11
|
+
|
|
12
|
+
help = "Create bootstrap AuditEvent records for audited model classes."
|
|
13
|
+
models = {}
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def setup_models(cls):
|
|
17
|
+
def model_name(model, unique=False):
|
|
18
|
+
if unique:
|
|
19
|
+
return f"{model._meta.app_label}.{model.__name__}"
|
|
20
|
+
return model.__name__
|
|
21
|
+
for model_class in get_audited_models():
|
|
22
|
+
name = model_name(model_class)
|
|
23
|
+
collision = cls.models.setdefault(name, model_class)
|
|
24
|
+
if collision is not model_class:
|
|
25
|
+
original = model_name(collision, unique=True)
|
|
26
|
+
namesake = model_name(model_class, unique=True)
|
|
27
|
+
if original == namesake:
|
|
28
|
+
raise InvalidModelState(
|
|
29
|
+
"Two audited models from the same app have the "
|
|
30
|
+
f"same name: {(collision, model_class)}"
|
|
31
|
+
)
|
|
32
|
+
del cls.models[name]
|
|
33
|
+
cls.models[original] = collision
|
|
34
|
+
cls.models[namesake] = model_class
|
|
35
|
+
|
|
36
|
+
def add_arguments(self, parser):
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"operation",
|
|
39
|
+
choices=self.operations,
|
|
40
|
+
help="Type of bootstrap operation to perform.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"models",
|
|
44
|
+
choices=sorted(self.models),
|
|
45
|
+
nargs="+",
|
|
46
|
+
help="Model class(es) to perform the bootstrap operation on.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--batch-size",
|
|
50
|
+
default=BOOTSTRAP_BATCH_SIZE,
|
|
51
|
+
metavar="N",
|
|
52
|
+
type=int,
|
|
53
|
+
help=(
|
|
54
|
+
"Perform database queries in batches of size %(metavar)s "
|
|
55
|
+
"(default=%(default)s). A value of zero (0) disables batching."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def handle(self, operation, models, batch_size, **options):
|
|
60
|
+
self.stdout.ending = None
|
|
61
|
+
self.logfile = self.stdout
|
|
62
|
+
if batch_size < 0:
|
|
63
|
+
raise CommandError("--batch-size must be a positive integer")
|
|
64
|
+
if batch_size == 0:
|
|
65
|
+
batch_size = None
|
|
66
|
+
self.batch_size = batch_size
|
|
67
|
+
|
|
68
|
+
self.service = get_audit_service()
|
|
69
|
+
|
|
70
|
+
for name in models:
|
|
71
|
+
model_class = self.models[name]
|
|
72
|
+
self.operations[operation](self, model_class)
|
|
73
|
+
|
|
74
|
+
def init_all(self, model_class):
|
|
75
|
+
query = model_class._default_manager.all()
|
|
76
|
+
log_head = f"init: {model_class} ({query.count()}) ... "
|
|
77
|
+
with self.bootstrap_action_log(log_head) as stream:
|
|
78
|
+
count = self.do_bootstrap(
|
|
79
|
+
model_class,
|
|
80
|
+
self.service.bootstrap_existing_model_records,
|
|
81
|
+
iter_records=query.iterator,
|
|
82
|
+
)
|
|
83
|
+
stream.write(f"done ({count})")
|
|
84
|
+
|
|
85
|
+
def top_up_missing(self, model_class):
|
|
86
|
+
log_head = f"top-up: {model_class} ... "
|
|
87
|
+
with self.bootstrap_action_log(log_head) as stream:
|
|
88
|
+
count = self.do_bootstrap(model_class, self.service.bootstrap_top_up)
|
|
89
|
+
stream.write(f"done ({count})")
|
|
90
|
+
|
|
91
|
+
def do_bootstrap(self, model_class, bootstrap_method, **bootstrap_kw):
|
|
92
|
+
field_names = self.service.get_field_names(model_class)
|
|
93
|
+
if not field_names:
|
|
94
|
+
raise CommandError(
|
|
95
|
+
f"invalid fields ({field_names!r}) for model: {model_class}"
|
|
96
|
+
)
|
|
97
|
+
return bootstrap_method(
|
|
98
|
+
model_class,
|
|
99
|
+
field_names,
|
|
100
|
+
batch_size=self.batch_size,
|
|
101
|
+
**bootstrap_kw,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
operations = {
|
|
105
|
+
"init": init_all,
|
|
106
|
+
"top-up": top_up_missing,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def bootstrap_action_log(self, *args, **kw):
|
|
111
|
+
end = kw.pop("end", "\n")
|
|
112
|
+
self.log_info(*args, **kw, end="")
|
|
113
|
+
yield self.logfile
|
|
114
|
+
self.logfile.write(end)
|
|
115
|
+
|
|
116
|
+
def log_info(self, *args, **kw):
|
|
117
|
+
self._log("INFO", *args, **kw)
|
|
118
|
+
|
|
119
|
+
def _log(self, level_name, message, *msg_args, end="\n"):
|
|
120
|
+
print(f"{level_name}: {message % msg_args}", file=self.logfile, end=end)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
Command.setup_models()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class InvalidModelState(CommandError):
|
|
127
|
+
pass
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .field_audit import request as audit_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FieldAuditMiddleware:
|
|
5
|
+
"""Middleware that gives the ``audit_fields()`` decorator access to the
|
|
6
|
+
Django request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, get_response):
|
|
10
|
+
self.get_response = get_response
|
|
11
|
+
|
|
12
|
+
def process_view(self, request, view_func, view_args, view_kwargs):
|
|
13
|
+
audit_request.set(request)
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
def __call__(self, request):
|
|
17
|
+
return self.get_response(request)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
from ..models import get_date
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.CreateModel(
|
|
14
|
+
name='AuditEvent',
|
|
15
|
+
fields=[
|
|
16
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # noqa: E501
|
|
17
|
+
('event_date', models.DateTimeField(db_index=True, default=get_date)), # noqa: E501
|
|
18
|
+
('object_class_path', models.CharField(db_index=True, max_length=255)), # noqa: E501
|
|
19
|
+
('object_pk', models.JSONField()),
|
|
20
|
+
('change_context', models.JSONField()),
|
|
21
|
+
('is_create', models.BooleanField(default=False)),
|
|
22
|
+
('is_delete', models.BooleanField(default=False)),
|
|
23
|
+
('delta', models.JSONField()),
|
|
24
|
+
],
|
|
25
|
+
),
|
|
26
|
+
migrations.AddConstraint(
|
|
27
|
+
model_name='auditevent',
|
|
28
|
+
constraint=models.CheckConstraint(
|
|
29
|
+
name='field_audit_auditevent_valid_create_or_delete',
|
|
30
|
+
check=models.Q(
|
|
31
|
+
('is_create', True),
|
|
32
|
+
('is_delete', True),
|
|
33
|
+
_negated=True,
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
dependencies = [
|
|
7
|
+
('field_audit', '0001_initial'),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.AddField(
|
|
12
|
+
model_name='auditevent',
|
|
13
|
+
name='is_bootstrap',
|
|
14
|
+
field=models.BooleanField(default=False),
|
|
15
|
+
),
|
|
16
|
+
migrations.AddConstraint(
|
|
17
|
+
model_name='auditevent',
|
|
18
|
+
constraint=models.CheckConstraint(
|
|
19
|
+
name='field_audit_auditevent_chk_create_or_delete_or_bootstrap',
|
|
20
|
+
check=~(
|
|
21
|
+
models.Q(is_create=True, is_delete=True) | \
|
|
22
|
+
models.Q(is_create=True, is_bootstrap=True) | \
|
|
23
|
+
models.Q(is_delete=True, is_bootstrap=True) # noqa: E502
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
migrations.RemoveConstraint(
|
|
28
|
+
model_name='auditevent',
|
|
29
|
+
name='field_audit_auditevent_valid_create_or_delete',
|
|
30
|
+
),
|
|
31
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Generated by Django 5.1.6 on 2025-06-27 15:11
|
|
2
|
+
|
|
3
|
+
import django.core.serializers.json
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('field_audit', '0002_add_is_bootstrap_column'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AlterField(
|
|
15
|
+
model_name='auditevent',
|
|
16
|
+
name='change_context',
|
|
17
|
+
field=models.JSONField(
|
|
18
|
+
encoder=django.core.serializers.json.DjangoJSONEncoder),
|
|
19
|
+
),
|
|
20
|
+
migrations.AlterField(
|
|
21
|
+
model_name='auditevent',
|
|
22
|
+
name='delta',
|
|
23
|
+
field=models.JSONField(
|
|
24
|
+
encoder=django.core.serializers.json.DjangoJSONEncoder),
|
|
25
|
+
),
|
|
26
|
+
migrations.AlterField(
|
|
27
|
+
model_name='auditevent',
|
|
28
|
+
name='object_pk',
|
|
29
|
+
field=models.JSONField(
|
|
30
|
+
encoder=django.core.serializers.json.DjangoJSONEncoder),
|
|
31
|
+
),
|
|
32
|
+
]
|
|
File without changes
|