django-field-audit 1.2.8__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.
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/PKG-INFO +10 -2
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/django_field_audit.egg-info/PKG-INFO +10 -2
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/django_field_audit.egg-info/SOURCES.txt +9 -1
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/__init__.py +1 -1
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/auditors.py +1 -1
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/setup.cfg +3 -1
- django_field_audit-1.2.9/tests/test_apps.py +18 -0
- django_field_audit-1.2.9/tests/test_auditors.py +238 -0
- django_field_audit-1.2.9/tests/test_bootstrap_field_audit_events.py +144 -0
- django_field_audit-1.2.9/tests/test_django_compat.py +148 -0
- django_field_audit-1.2.9/tests/test_field_audit.py +178 -0
- django_field_audit-1.2.9/tests/test_middleware.py +25 -0
- django_field_audit-1.2.9/tests/test_models.py +1424 -0
- django_field_audit-1.2.9/tests/test_utils.py +83 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/LICENSE +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/README.md +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/django_field_audit.egg-info/dependency_links.txt +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/django_field_audit.egg-info/top_level.txt +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/apps.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/const.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/field_audit.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/management/__init__.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/management/commands/__init__.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/management/commands/bootstrap_field_audit_events.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/middleware.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/migrations/0001_initial.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/migrations/0002_add_is_bootstrap_column.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/migrations/__init__.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/models.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/field_audit/utils.py +0 -0
- {django-field-audit-1.2.8 → django_field_audit-1.2.9}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: django-field-audit
|
|
3
|
-
Version: 1.2.
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: django-field-audit
|
|
3
|
-
Version: 1.2.
|
|
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
|
|
{django-field-audit-1.2.8 → django_field_audit-1.2.9}/django_field_audit.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
@@ -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:
|
|
@@ -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)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import django
|
|
2
|
+
from django.test import TestCase
|
|
3
|
+
|
|
4
|
+
from field_audit.models import AuditEvent
|
|
5
|
+
|
|
6
|
+
from .models import ModelWithValueOnSave, SimpleModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestAuditedDbWrites(TestCase):
|
|
10
|
+
|
|
11
|
+
def test_model_delete_is_audited(self):
|
|
12
|
+
self.assertNoAuditEvents()
|
|
13
|
+
instance = SimpleModel.objects.create(id=0, value='test')
|
|
14
|
+
AuditEvent.objects.all().delete() # delete the create audit event
|
|
15
|
+
instance.delete()
|
|
16
|
+
self.assertAuditEvent(
|
|
17
|
+
is_delete=True,
|
|
18
|
+
delta={"id": {"old": 0}, "value": {"old": 'test'}},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def test_model_delete_with_value_on_save_is_audited(self):
|
|
22
|
+
self.assertNoAuditEvents()
|
|
23
|
+
instance = ModelWithValueOnSave.objects.create(id=0)
|
|
24
|
+
AuditEvent.objects.all().delete() # delete the create audit event
|
|
25
|
+
instance.delete()
|
|
26
|
+
self.assertAuditEvent(
|
|
27
|
+
is_delete=True,
|
|
28
|
+
delta={'id': {'old': 0}, 'save_count': {'old': 1}},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def test_model_save_is_audited(self):
|
|
32
|
+
self.assertNoAuditEvents()
|
|
33
|
+
SimpleModel(id=0).save()
|
|
34
|
+
self.assertAuditEvent(
|
|
35
|
+
is_create=True,
|
|
36
|
+
delta={"id": {"new": 0}, "value": {"new": None}},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def test_model_save_with_value_on_save_is_audited(self):
|
|
40
|
+
self.assertNoAuditEvents()
|
|
41
|
+
ModelWithValueOnSave.objects.create(id=0)
|
|
42
|
+
self.assertAuditEvent(
|
|
43
|
+
is_create=True,
|
|
44
|
+
delta={'id': {'new': 0}, 'save_count': {'new': 1}},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def test_model_multiple_saves_with_value_on_save_is_audited(self):
|
|
48
|
+
self.assertNoAuditEvents()
|
|
49
|
+
instance = ModelWithValueOnSave.objects.create(id=0)
|
|
50
|
+
AuditEvent.objects.all().delete() # delete the create audit event
|
|
51
|
+
instance.value = 'update'
|
|
52
|
+
instance.save()
|
|
53
|
+
self.assertAuditEvent(
|
|
54
|
+
is_create=False,
|
|
55
|
+
delta={'save_count': {'old': 1, 'new': 2}},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def test_queryset_bulk_create_is_not_audited(self):
|
|
59
|
+
self.assertNoAuditEvents()
|
|
60
|
+
instance, = SimpleModel.objects.bulk_create([SimpleModel(id=1)])
|
|
61
|
+
instance.refresh_from_db()
|
|
62
|
+
self.assertIsNotNone(instance.id)
|
|
63
|
+
self.assertNoAuditEvents()
|
|
64
|
+
|
|
65
|
+
def test_queryset_bulk_update_is_not_audited(self):
|
|
66
|
+
instance = SimpleModel.objects.create(id=1)
|
|
67
|
+
AuditEvent.objects.all().delete() # delete the create audit event
|
|
68
|
+
self.assertIsNone(instance.value)
|
|
69
|
+
instance.value = "test"
|
|
70
|
+
updates = SimpleModel.objects.bulk_update([instance], ["value"])
|
|
71
|
+
if django.VERSION[0] < 4:
|
|
72
|
+
expected_updates = None
|
|
73
|
+
else:
|
|
74
|
+
expected_updates = 1
|
|
75
|
+
self.assertEqual(expected_updates, updates)
|
|
76
|
+
instance.refresh_from_db()
|
|
77
|
+
self.assertEqual("test", instance.value)
|
|
78
|
+
self.assertNoAuditEvents()
|
|
79
|
+
|
|
80
|
+
def test_queryset_create_is_audited(self):
|
|
81
|
+
self.assertNoAuditEvents()
|
|
82
|
+
instance = SimpleModel.objects.create()
|
|
83
|
+
self.assertAuditEvent(
|
|
84
|
+
is_create=True,
|
|
85
|
+
delta={"id": {"new": instance.id}, "value": {"new": None}},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def test_queryset_delete_is_not_audited(self):
|
|
89
|
+
self.assertNoAuditEvents()
|
|
90
|
+
instance = SimpleModel.objects.create()
|
|
91
|
+
AuditEvent.objects.all().delete() # delete the create audit event
|
|
92
|
+
instance.refresh_from_db()
|
|
93
|
+
self.assertIsNotNone(instance.id)
|
|
94
|
+
SimpleModel.objects.all().delete()
|
|
95
|
+
self.assertNoAuditEvents()
|
|
96
|
+
|
|
97
|
+
def test_queryset_get_or_create_is_audited(self):
|
|
98
|
+
self.assertNoAuditEvents()
|
|
99
|
+
self.assertEqual([], list(SimpleModel.objects.all()))
|
|
100
|
+
SimpleModel.objects.get_or_create(id=0)
|
|
101
|
+
self.assertAuditEvent(
|
|
102
|
+
is_create=True,
|
|
103
|
+
delta={"id": {"new": 0}, "value": {"new": None}},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_queryset_update_is_not_audited(self):
|
|
107
|
+
self.assertNoAuditEvents()
|
|
108
|
+
instance = SimpleModel.objects.create()
|
|
109
|
+
AuditEvent.objects.all().delete() # delete the create audit event
|
|
110
|
+
SimpleModel.objects.all().update(value="test")
|
|
111
|
+
instance.refresh_from_db()
|
|
112
|
+
self.assertEqual("test", instance.value)
|
|
113
|
+
self.assertNoAuditEvents()
|
|
114
|
+
|
|
115
|
+
def test_queryset_update_or_create_is_audited_on_create(self):
|
|
116
|
+
self.assertNoAuditEvents()
|
|
117
|
+
self.assertEqual([], list(SimpleModel.objects.all()))
|
|
118
|
+
response = SimpleModel.objects.update_or_create({"value": "test"}, id=0)
|
|
119
|
+
instance, x = response
|
|
120
|
+
self.assertEqual(0, instance.id)
|
|
121
|
+
self.assertEqual("test", instance.value)
|
|
122
|
+
self.assertAuditEvent(
|
|
123
|
+
is_create=True,
|
|
124
|
+
delta={"id": {"new": 0}, "value": {"new": "test"}},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_queryset_update_or_create_is_audited_on_update(self):
|
|
128
|
+
self.assertNoAuditEvents()
|
|
129
|
+
instance = SimpleModel.objects.create()
|
|
130
|
+
AuditEvent.objects.all().delete() # delete the create audit event
|
|
131
|
+
self.assertEqual([instance], list(SimpleModel.objects.all()))
|
|
132
|
+
self.assertIsNone(instance.value)
|
|
133
|
+
SimpleModel.objects.update_or_create({"value": "test"}, id=instance.id)
|
|
134
|
+
instance.refresh_from_db()
|
|
135
|
+
self.assertEqual("test", instance.value)
|
|
136
|
+
self.assertAuditEvent(
|
|
137
|
+
is_create=False,
|
|
138
|
+
is_delete=False,
|
|
139
|
+
delta={"value": {"old": None, "new": "test"}},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def assertNoAuditEvents(self):
|
|
143
|
+
self.assertEqual([], list(AuditEvent.objects.all()))
|
|
144
|
+
|
|
145
|
+
def assertAuditEvent(self, **kwargs):
|
|
146
|
+
event, = AuditEvent.objects.all()
|
|
147
|
+
for name, value in kwargs.items():
|
|
148
|
+
self.assertEqual(value, getattr(event, name))
|