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,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