django-field-audit 1.2.7__tar.gz → 1.2.9__tar.gz

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.

Files changed (31) hide show
  1. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/PKG-INFO +12 -3
  2. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/README.md +2 -1
  3. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/django_field_audit.egg-info/PKG-INFO +12 -3
  4. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/django_field_audit.egg-info/SOURCES.txt +9 -1
  5. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/__init__.py +1 -1
  6. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/auditors.py +1 -1
  7. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/models.py +2 -2
  8. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/setup.cfg +3 -1
  9. django_field_audit-1.2.9/tests/test_apps.py +18 -0
  10. django_field_audit-1.2.9/tests/test_auditors.py +238 -0
  11. django_field_audit-1.2.9/tests/test_bootstrap_field_audit_events.py +144 -0
  12. django_field_audit-1.2.9/tests/test_django_compat.py +148 -0
  13. django_field_audit-1.2.9/tests/test_field_audit.py +178 -0
  14. django_field_audit-1.2.9/tests/test_middleware.py +25 -0
  15. django_field_audit-1.2.9/tests/test_models.py +1424 -0
  16. django_field_audit-1.2.9/tests/test_utils.py +83 -0
  17. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/LICENSE +0 -0
  18. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/django_field_audit.egg-info/dependency_links.txt +0 -0
  19. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/django_field_audit.egg-info/top_level.txt +0 -0
  20. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/apps.py +0 -0
  21. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/const.py +0 -0
  22. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/field_audit.py +0 -0
  23. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/management/__init__.py +0 -0
  24. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/management/commands/__init__.py +0 -0
  25. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/management/commands/bootstrap_field_audit_events.py +0 -0
  26. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/middleware.py +0 -0
  27. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/migrations/0001_initial.py +0 -0
  28. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
  29. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/migrations/__init__.py +0 -0
  30. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/field_audit/utils.py +0 -0
  31. {django-field-audit-1.2.7 → django_field_audit-1.2.9}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: django-field-audit
3
- Version: 1.2.7
3
+ Version: 1.2.9
4
4
  Summary: Audit Field Changes on Django Models
5
5
  Home-page: https://github.com/dimagi/django-field-audit
6
6
  Maintainer: Joel Miller
@@ -26,6 +26,14 @@ Classifier: Framework :: Django :: 4.2
26
26
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
27
  Description-Content-Type: text/markdown
28
28
  License-File: LICENSE
29
+ Dynamic: classifier
30
+ Dynamic: description
31
+ Dynamic: description-content-type
32
+ Dynamic: home-page
33
+ Dynamic: license
34
+ Dynamic: maintainer
35
+ Dynamic: maintainer-email
36
+ Dynamic: summary
29
37
 
30
38
  # Audit Field Changes on Django Models
31
39
 
@@ -274,7 +282,8 @@ python example/manage.py makemigrations field_audit
274
282
 
275
283
  ### Uploading to PyPI
276
284
 
277
- Package and upload the generated files.
285
+ First bump the package version in the `field_audit/__init__.py` file. Then create a changelog entry in the CHANGELOG.md
286
+ file. After these changes are merged, you should tag the main branch with the new version. Then, package and upload the generated files to PyPI.
278
287
 
279
288
  ```shell
280
289
  pip install -r pkg-requires.txt
@@ -245,7 +245,8 @@ python example/manage.py makemigrations field_audit
245
245
 
246
246
  ### Uploading to PyPI
247
247
 
248
- Package and upload the generated files.
248
+ First bump the package version in the `field_audit/__init__.py` file. Then create a changelog entry in the CHANGELOG.md
249
+ file. After these changes are merged, you should tag the main branch with the new version. Then, package and upload the generated files to PyPI.
249
250
 
250
251
  ```shell
251
252
  pip install -r pkg-requires.txt
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: django-field-audit
3
- Version: 1.2.7
3
+ Version: 1.2.9
4
4
  Summary: Audit Field Changes on Django Models
5
5
  Home-page: https://github.com/dimagi/django-field-audit
6
6
  Maintainer: Joel Miller
@@ -26,6 +26,14 @@ Classifier: Framework :: Django :: 4.2
26
26
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
27
  Description-Content-Type: text/markdown
28
28
  License-File: LICENSE
29
+ Dynamic: classifier
30
+ Dynamic: description
31
+ Dynamic: description-content-type
32
+ Dynamic: home-page
33
+ Dynamic: license
34
+ Dynamic: maintainer
35
+ Dynamic: maintainer-email
36
+ Dynamic: summary
29
37
 
30
38
  # Audit Field Changes on Django Models
31
39
 
@@ -274,7 +282,8 @@ python example/manage.py makemigrations field_audit
274
282
 
275
283
  ### Uploading to PyPI
276
284
 
277
- Package and upload the generated files.
285
+ First bump the package version in the `field_audit/__init__.py` file. Then create a changelog entry in the CHANGELOG.md
286
+ file. After these changes are merged, you should tag the main branch with the new version. Then, package and upload the generated files to PyPI.
278
287
 
279
288
  ```shell
280
289
  pip install -r pkg-requires.txt
@@ -19,4 +19,12 @@ field_audit/management/commands/__init__.py
19
19
  field_audit/management/commands/bootstrap_field_audit_events.py
20
20
  field_audit/migrations/0001_initial.py
21
21
  field_audit/migrations/0002_add_is_bootstrap_column.py
22
- field_audit/migrations/__init__.py
22
+ field_audit/migrations/__init__.py
23
+ tests/test_apps.py
24
+ tests/test_auditors.py
25
+ tests/test_bootstrap_field_audit_events.py
26
+ tests/test_django_compat.py
27
+ tests/test_field_audit.py
28
+ tests/test_middleware.py
29
+ tests/test_models.py
30
+ tests/test_utils.py
@@ -1,3 +1,3 @@
1
1
  from .field_audit import audit_fields # noqa: F401
2
2
 
3
- __version__ = "1.2.7"
3
+ __version__ = "1.2.9"
@@ -107,7 +107,7 @@ class SystemUserAuditor(BaseAuditor):
107
107
  try:
108
108
  # get owner of STDIN file on login sessions (e.g. SSH)
109
109
  output = check_output(["who", "-m"], stderr=DEVNULL)
110
- except CalledProcessError:
110
+ except (FileNotFoundError, CalledProcessError):
111
111
  self.has_who_bin = False
112
112
  else:
113
113
  if output:
@@ -1,4 +1,3 @@
1
- from datetime import datetime
2
1
  from enum import Enum
3
2
  from functools import wraps
4
3
  from itertools import islice
@@ -6,6 +5,7 @@ from itertools import islice
6
5
  from django.conf import settings
7
6
  from django.db import models, transaction
8
7
  from django.db.models import Expression
8
+ from django.utils import timezone
9
9
 
10
10
  from .const import BOOTSTRAP_BATCH_SIZE
11
11
  from .utils import class_import_helper
@@ -197,7 +197,7 @@ def get_date():
197
197
  This is the "getter" for default values of the ``AuditEvent.event_date``
198
198
  field.
199
199
  """
200
- return datetime.utcnow()
200
+ return timezone.now()
201
201
 
202
202
 
203
203
  class AuditEvent(models.Model):
@@ -6,7 +6,9 @@ license_file = LICENSE
6
6
 
7
7
  [flake8]
8
8
  max-line-length = 80
9
- exclude = ./build
9
+ exclude =
10
+ ./build
11
+ .venv
10
12
 
11
13
  [egg_info]
12
14
  tag_build =
@@ -0,0 +1,18 @@
1
+ from django.apps import apps
2
+ from django.test import TestCase
3
+
4
+ from field_audit.auditors import audit_dispatcher
5
+
6
+
7
+ class TestFieldAuditConfig(TestCase):
8
+
9
+ def test_config_ready_sets_auditors(self):
10
+ # ensure it's not empty already
11
+ self.assertNotEqual([], audit_dispatcher.auditors)
12
+ try:
13
+ audit_dispatcher.auditors = []
14
+ apps.get_app_config("field_audit").ready()
15
+ self.assertNotEqual([], audit_dispatcher.auditors)
16
+ finally:
17
+ # reset to defaults
18
+ audit_dispatcher.setup_auditors()
@@ -0,0 +1,238 @@
1
+ from subprocess import CalledProcessError
2
+ from unittest.mock import patch
3
+
4
+ from django.conf import settings
5
+ from django.test import TestCase
6
+ from django.test.utils import override_settings
7
+
8
+ from field_audit.auditors import (
9
+ BaseAuditor,
10
+ RequestAuditor,
11
+ SystemUserAuditor,
12
+ audit_dispatcher,
13
+ )
14
+ from field_audit.models import (
15
+ USER_TYPE_PROCESS,
16
+ USER_TYPE_REQUEST,
17
+ USER_TYPE_TTY,
18
+ )
19
+
20
+
21
+ class TestAuditDispatcher(TestCase):
22
+
23
+ def setUp(self):
24
+ # reset the auditor chain to default values
25
+ audit_dispatcher.setup_auditors()
26
+
27
+ def test_audit_dispatcher_default_auditor_chain(self):
28
+ with self.assertRaises(AttributeError):
29
+ getattr(settings, "FIELD_AUDIT_AUDITORS")
30
+ request_auditor, sysuser_auditor = audit_dispatcher.auditors
31
+ self.assertIsInstance(request_auditor, RequestAuditor)
32
+ self.assertIsInstance(sysuser_auditor, SystemUserAuditor)
33
+
34
+ @override_settings(
35
+ FIELD_AUDIT_AUDITORS=[
36
+ "tests.test_auditors.CustomAuditor1",
37
+ "tests.test_auditors.CustomAuditor2",
38
+ "tests.test_auditors.CustomAuditor3",
39
+ ],
40
+ )
41
+ def test_audit_dispatcher_auditor_chain_is_configurable(self):
42
+ audit_dispatcher.setup_auditors()
43
+ one, two, three = audit_dispatcher.auditors
44
+ self.assertIsInstance(one, CustomAuditor1)
45
+ self.assertIsInstance(two, CustomAuditor2)
46
+ self.assertIsInstance(three, CustomAuditor3)
47
+
48
+ @override_settings(FIELD_AUDIT_AUDITORS=[])
49
+ def test_audit_dispatcher_setting_empty_auditor_chain_clears_auditors(self):
50
+ audit_dispatcher.setup_auditors()
51
+ self.assertEqual([], audit_dispatcher.auditors)
52
+
53
+ @override_settings(FIELD_AUDIT_AUDITORS=["tests.models.Flight"])
54
+ def test_audit_dispatcher_custom_auditors_must_subclass_baseauditor(self):
55
+ from .models import Flight
56
+ self.assertFalse(issubclass(Flight, BaseAuditor))
57
+ with self.assertRaises(ValueError):
58
+ audit_dispatcher.setup_auditors()
59
+
60
+ def test_audit_dispatcher_chain(self):
61
+ aud1 = MockAuditor(True)
62
+ aud2 = MockAuditor(True)
63
+ chain = [aud1, aud2]
64
+
65
+ # aud1 hits, aud2 never called
66
+ with patch.object(audit_dispatcher, "auditors", chain):
67
+ change_context = audit_dispatcher.dispatch(object())
68
+ self.assertIs(change_context, aud1)
69
+ self.assertEqual(aud1.dispatched, 1)
70
+ self.assertEqual(aud2.dispatched, 0)
71
+
72
+ # aud1 misses, aud2 hits
73
+ aud1.reset(False)
74
+ with patch.object(audit_dispatcher, "auditors", chain):
75
+ change_context = audit_dispatcher.dispatch(object())
76
+ self.assertIs(change_context, aud2)
77
+ self.assertEqual(aud1.dispatched, 1)
78
+ self.assertEqual(aud2.dispatched, 1)
79
+
80
+ # both miss
81
+ aud1.reset(False)
82
+ aud2.reset(False)
83
+ with patch.object(audit_dispatcher, "auditors", chain):
84
+ change_context = audit_dispatcher.dispatch(object())
85
+ self.assertIsNone(change_context)
86
+ self.assertEqual(aud1.dispatched, 1)
87
+ self.assertEqual(aud2.dispatched, 1)
88
+
89
+
90
+ class CustomAuditor1(BaseAuditor):
91
+ pass
92
+
93
+
94
+ class CustomAuditor2(BaseAuditor):
95
+ pass
96
+
97
+
98
+ class CustomAuditor3(BaseAuditor):
99
+ pass
100
+
101
+
102
+ class MockAuditor:
103
+
104
+ def __init__(self, enabled):
105
+ self.reset(enabled)
106
+
107
+ def reset(self, enabled):
108
+ self.dispatched = 0
109
+ self.enabled = enabled
110
+
111
+ def change_context(self, request):
112
+ self.dispatched += 1
113
+ return self if self.enabled else None
114
+
115
+
116
+ class TestBaseAuditor(TestCase):
117
+
118
+ def test_baseauditor_change_context_raises_notimplementederror(self):
119
+ with self.assertRaises(NotImplementedError):
120
+ BaseAuditor().change_context(object())
121
+
122
+
123
+ class TestSystemUserAuditor(TestCase):
124
+
125
+ def setUp(self):
126
+ super().setUp()
127
+ self.auditor = SystemUserAuditor()
128
+
129
+ def test_systemuserauditor_change_context_returns_sys_value_for_request(self): # noqa: E501
130
+ def user(*args, **kw):
131
+ return b"test ..."
132
+ with patch("field_audit.auditors.check_output", side_effect=user):
133
+ self.assertEqual(
134
+ {"user_type": USER_TYPE_TTY, "username": "test"},
135
+ self.auditor.change_context(object()),
136
+ )
137
+
138
+ def _patch_system_getters_and_validate(self, fake_output, change_context):
139
+ kwargs = {"side_effect": fake_output}
140
+ user = None if change_context is None else change_context["username"]
141
+ with (
142
+ patch("field_audit.auditors.check_output", **kwargs) as chk_out,
143
+ patch("field_audit.auditors.getuser", return_value=user) as getuser,
144
+ ):
145
+ audit_info = self.auditor.change_context(None)
146
+ if isinstance(audit_info, dict):
147
+ self.assertEqual(change_context, audit_info)
148
+ else:
149
+ self.assertIsNone(audit_info)
150
+ return chk_out, getuser
151
+
152
+ def test_systemuserauditor_change_context_tolerates_invalid_who_output(self): # noqa: E501
153
+ def bogus(*args, **kw):
154
+ return b"\xb4"
155
+ ch_by = {"user_type": USER_TYPE_PROCESS, "username": "alice"}
156
+ chk_out, getuser = self._patch_system_getters_and_validate(bogus, ch_by)
157
+ chk_out.assert_called_once()
158
+ getuser.assert_called_once()
159
+
160
+ def test_systemuserauditor_change_context_tolerates_empty_who_output(self):
161
+ def empty(*args, **kw):
162
+ return b""
163
+ ch_by = {"user_type": USER_TYPE_PROCESS, "username": "bob"}
164
+ chk_out, getuser = self._patch_system_getters_and_validate(empty, ch_by)
165
+ chk_out.assert_called_once()
166
+ getuser.assert_called_once()
167
+
168
+ def test_systemuserauditor_remembers_who_error(self):
169
+ def fail(*args, **kw):
170
+ raise CalledProcessError(1, [], "")
171
+ ch_by = {"user_type": USER_TYPE_PROCESS, "username": "carlos"}
172
+ # round 1
173
+ chk_out, getuser = self._patch_system_getters_and_validate(fail, ch_by)
174
+ chk_out.assert_called_once()
175
+ getuser.assert_called_once()
176
+ # round 2
177
+ chk_out, getuser = self._patch_system_getters_and_validate(fail, ch_by)
178
+ chk_out.assert_not_called()
179
+ getuser.assert_called_once()
180
+
181
+ def test_systemuserauditor_handles_missing_who_bin(self):
182
+ def fail(*args, **kw):
183
+ raise FileNotFoundError()
184
+ ch_by = {"user_type": USER_TYPE_PROCESS, "username": "carlos"}
185
+ chk_out, getuser = self._patch_system_getters_and_validate(fail, ch_by)
186
+ chk_out.assert_called_once()
187
+ getuser.assert_called_once()
188
+
189
+ def test_systemuserauditor_change_context_returns_tty_user_on_who_output(self): # noqa: E501
190
+ def mock(*args, **kw):
191
+ return b"eve ttys000 May 8 17:04 (localhost)"
192
+ ch_by = {"user_type": USER_TYPE_TTY, "username": "eve"}
193
+ chk_out, getuser = self._patch_system_getters_and_validate(mock, ch_by)
194
+ chk_out.assert_called_once()
195
+ getuser.assert_not_called()
196
+
197
+ def test_systemuserauditor_change_context_returns_none_if_all_else_fails(self): # noqa: E501
198
+ def empty(*args, **kw):
199
+ return b""
200
+ chk_out, getuser = self._patch_system_getters_and_validate(empty, None)
201
+ chk_out.assert_called_once()
202
+ getuser.assert_called_once()
203
+
204
+
205
+ class TestRequestAuditor(TestCase):
206
+
207
+ def setUp(self):
208
+ self.request = AuthedRequest()
209
+ self.auditor = RequestAuditor()
210
+
211
+ def test_requestauditor_change_context(self):
212
+ self.assertEqual(
213
+ {
214
+ "user_type": USER_TYPE_REQUEST,
215
+ "username": self.request.user.username,
216
+ },
217
+ self.auditor.change_context(self.request),
218
+ )
219
+
220
+ def test_requestauditor_change_context_returns_none_without_request(self):
221
+ self.assertIsNone(self.auditor.change_context(None))
222
+
223
+ def test_requestauditor_change_context_returns_value_for_unauthorized_req(self): # noqa: E501
224
+ self.request.deauth()
225
+ self.assertEqual({}, self.auditor.change_context(self.request))
226
+
227
+
228
+ class AuthedRequest:
229
+
230
+ class User:
231
+ username = "test@example.com"
232
+ is_authenticated = True
233
+
234
+ def __init__(self):
235
+ self.user = self.User()
236
+
237
+ def deauth(self):
238
+ self.user.is_authenticated = False
@@ -0,0 +1,144 @@
1
+ from contextlib import contextmanager
2
+ from io import StringIO
3
+ from unittest.mock import ANY, patch
4
+
5
+ from django.core.management import CommandError, call_command
6
+ from django.db import models
7
+ from django.test import TestCase
8
+
9
+ from field_audit.const import BOOTSTRAP_BATCH_SIZE
10
+ from field_audit.field_audit import get_audited_models
11
+ from field_audit.management.commands import (
12
+ bootstrap_field_audit_events as bootstrap,
13
+ )
14
+ from field_audit.models import AuditEvent
15
+ from tests.models import PkAuto
16
+
17
+
18
+ @contextmanager
19
+ def restore_command_models(command_class):
20
+ backup = command_class.models
21
+ yield
22
+ command_class.models = backup
23
+
24
+
25
+ class TestCommand(TestCase):
26
+
27
+ command = "bootstrap_field_audit_events"
28
+
29
+ def setUp(self):
30
+ super().setUp()
31
+ # create some records for bootstrapping
32
+ for _ in range(2):
33
+ PkAuto.objects.create()
34
+ # clear the "create" audit events
35
+ AuditEvent.objects.all().delete()
36
+
37
+ def test_setup_models_populates_models_dict(self):
38
+ with restore_command_models(bootstrap.Command):
39
+ bootstrap.Command.models = {}
40
+ bootstrap.Command.setup_models()
41
+ self.assertEqual(
42
+ set(get_audited_models()),
43
+ set(bootstrap.Command.models.values()),
44
+ )
45
+
46
+ def test_setup_models_uses_model_name(self):
47
+ with restore_command_models(bootstrap.Command):
48
+ bootstrap.Command.models = {}
49
+ bootstrap.Command.setup_models()
50
+ self.assertIs(PkAuto, bootstrap.Command.models["PkAuto"])
51
+
52
+ def test_setup_models_uses_app_name_to_prevent_model_name_collisions(self):
53
+ with restore_command_models(bootstrap.Command):
54
+ class ModelX(models.Model):
55
+ pass
56
+ bootstrap.Command.models = {"PkAuto": ModelX}
57
+ patch_kw = {"return_value": {PkAuto: "cls"}}
58
+ with patch.object(bootstrap, "get_audited_models", **patch_kw):
59
+ bootstrap.Command.setup_models()
60
+ self.assertEqual(
61
+ {"tests.ModelX": ModelX, "tests.PkAuto": PkAuto},
62
+ bootstrap.Command.models,
63
+ )
64
+
65
+ def test_setup_models_crashes_on_verbose_model_name_collision(self):
66
+
67
+ class ModelY(models.Model):
68
+ pass
69
+
70
+ class ModelZ(models.Model):
71
+ pass
72
+
73
+ ModelZ.__name__ = ModelY.__name__
74
+ with restore_command_models(bootstrap.Command):
75
+ patch_kw = {"return_value": {ModelY: "y", ModelZ: "z"}}
76
+ with (
77
+ patch.object(bootstrap, "get_audited_models", **patch_kw),
78
+ self.assertRaises(bootstrap.InvalidModelState),
79
+ ):
80
+ bootstrap.Command.setup_models()
81
+
82
+ def test_bootstrap_uses_default_batch_size(self):
83
+ with patch.object(AuditEvent, "bootstrap_top_up") as mock:
84
+ self.quiet_command("top-up", "PkAuto")
85
+ mock.assert_called_once_with(
86
+ PkAuto, ANY, batch_size=BOOTSTRAP_BATCH_SIZE,
87
+ )
88
+
89
+ def test_bootstrap_allows_custom_batch_size(self):
90
+ with patch.object(AuditEvent, "bootstrap_top_up") as mock:
91
+ self.quiet_command("top-up", "--batch-size", "1", "PkAuto")
92
+ mock.assert_called_once_with(PkAuto, ANY, batch_size=1)
93
+
94
+ def test_bootstrap_disables_batching_for_batch_size_zero(self):
95
+ with patch.object(AuditEvent, "bootstrap_top_up") as mock:
96
+ self.quiet_command("top-up", "--batch-size", "0", "PkAuto")
97
+ mock.assert_called_once_with(PkAuto, ANY, batch_size=None)
98
+
99
+ def test_bootstrap_crashes_for_negative_batch_size(self):
100
+ with self.assertRaises(CommandError):
101
+ self.quiet_command("top-up", "--batch-size", "-1", "PkAuto")
102
+
103
+ def test_bootstrap_crashes_early_if_model_has_invalid_fields(self):
104
+ with (
105
+ patch.object(PkAuto, AuditEvent.ATTACH_FIELD_NAMES_AT, []),
106
+ self.assertRaises(CommandError),
107
+ ):
108
+ self.quiet_command("init", "PkAuto")
109
+
110
+ def test_bootstrap_init_creates_audit_events_for_all_model_records(self):
111
+ self.quiet_command("init", "PkAuto")
112
+ self.assertEqual(
113
+ set(PkAuto.objects.all().values_list("pk", flat=True)),
114
+ set(
115
+ AuditEvent.objects.by_model(PkAuto)
116
+ .filter(is_bootstrap=True)
117
+ .values_list("object_pk", flat=True)
118
+ ),
119
+ )
120
+
121
+ def test_bootstrap_top_up_creates_audit_events_for_some_model_records(self):
122
+ need_bootstrap = set(PkAuto.objects.all().values_list("pk", flat=True))
123
+ # generate two more models (generates two "create" audit events)
124
+ for _ in range(2):
125
+ PkAuto.objects.create()
126
+ # convert one of them to a bootstrap event
127
+ event = AuditEvent.objects.first()
128
+ event.is_create = False
129
+ event.is_bootstrap = True
130
+ event.save()
131
+ pre_top_up_event_ids = set(
132
+ AuditEvent.objects.all().values_list("id", flat=True)
133
+ )
134
+ self.quiet_command("top-up", "PkAuto")
135
+ self.assertEqual(
136
+ need_bootstrap,
137
+ set(
138
+ AuditEvent.objects.exclude(id__in=pre_top_up_event_ids)
139
+ .values_list("object_pk", flat=True)
140
+ ),
141
+ )
142
+
143
+ def quiet_command(self, *args, **kw):
144
+ return call_command(self.command, *args, stdout=StringIO(), **kw)