Skip to content

Commit 155e852

Browse files
Ross Mechanicbmampaey
andauthored
Add default_user or default_change_reason for bulk_create or bulk_update (#653)
* Added possibility to specify a default user for bulk_create_with_history * format * added tests * flake * added more test coverage Co-authored-by: Benjamin Mampaey <[email protected]>
1 parent 3f73db3 commit 155e852

File tree

7 files changed

+236
-16
lines changed

7 files changed

+236
-16
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Authors
1616
- Alexander Anikeev
1717
- Amanda Ng (`AmandaCLNg <https://github.com/AmandaCLNg>`_)
1818
- Ben Lawson (`blawson <https://github.com/blawson>`_)
19+
- Benjamin Mampaey (`bmampaey <https://github.com/bmampaey>`_)
1920
- `bradford281 <https://github.com/bradford281>`_
2021
- Brian Armstrong (`barm <https://github.com/barm>`_)
2122
- Buddy Lindsey, Jr.

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Changes
44
Unreleased
55
------------
66
- Added `bulk_update_with_history` utility function (gh-650)
7+
- Add default user and default change reason to `bulk_create_with_history` and `bulk_update_with_history`
78

89
2.9.0 (2020-04-23)
910
------------------

docs/common_issues.rst

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,28 @@ history:
3232
>>> Poll.history.count()
3333
1000
3434
35-
If you want to specify a change reason for each record in the bulk create, you
36-
can add `changeReason` on each instance:
35+
If you want to specify a change reason or history user for each record in the bulk create,
36+
you can add `changeReason` or `_history_user` on each instance:
3737

3838
.. code-block:: pycon
3939
4040
>>> for poll in data:
4141
poll.changeReason = 'reason'
42+
poll._history_user = my_user
4243
>>> objs = bulk_create_with_history(data, Poll, batch_size=500)
4344
>>> Poll.history.get(id=data[0].id).history_change_reason
4445
'reason'
4546
47+
You can also specify a default user or default change reason responsible for the change
48+
(`_history_user` and `changeReason` take precedence).
49+
50+
.. code-block:: pycon
51+
52+
>>> user = User.objects.create_user("tester", "[email protected]")
53+
>>> objs = bulk_create_with_history(data, Poll, batch_size=500, default_user=user)
54+
>>> Poll.history.get(id=data[0].id).history_user == user
55+
True
56+
4657
Bulk Updating a Model with History (New)
4758
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4859
Bulk update was introduced with Django 2.2. We can use the utility function
@@ -58,11 +69,11 @@ Bulk update was introduced with Django 2.2. We can use the utility function
5869
>>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)]
5970
>>> objs = bulk_create_with_history(data, Poll, batch_size=500)
6071
>>> for obj in objs: obj.question = 'Duplicate Questions'
61-
>>> bulk_update_with_history(objs, Poll, ['question'], batch_size=500)
72+
>>> bulk_update_with_history(objs, Poll, ['question'], batch_size=500)
6273
>>> Poll.objects.first().question
6374
'Duplicate Question'
6475
65-
QuerySet Updates with History (Updated in Django 2.2)
76+
QuerySet Updates with History (Updated in Django 2.2)
6677
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6778
Unlike with ``bulk_create``, `queryset updates`_ perform an SQL update query on
6879
the queryset, and never return the actual updated objects (which would be
@@ -81,7 +92,7 @@ As the Django documentation says::
8192
8293
.. _queryset updates: https://docs.djangoproject.com/en/2.2/ref/models/querysets/#update
8394

84-
Note: Django 2.2 now allows ``bulk_update``. No ``pre_save`` or ``post_save`` signals are sent still.
95+
Note: Django 2.2 now allows ``bulk_update``. No ``pre_save`` or ``post_save`` signals are sent still.
8596

8697
Tracking Custom Users
8798
---------------------

simple_history/manager.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,14 @@ def _as_of_set(self, date):
9898
continue
9999
yield last_change.instance
100100

101-
def bulk_history_create(self, objs, batch_size=None, update=False):
101+
def bulk_history_create(
102+
self,
103+
objs,
104+
batch_size=None,
105+
update=False,
106+
default_user=None,
107+
default_change_reason="",
108+
):
102109
"""
103110
Bulk create the history for the objects specified by objs.
104111
If called by bulk_update_with_history, use the update boolean and
@@ -113,8 +120,10 @@ def bulk_history_create(self, objs, batch_size=None, update=False):
113120
for instance in objs:
114121
row = self.model(
115122
history_date=getattr(instance, "_history_date", timezone.now()),
116-
history_user=getattr(instance, "_history_user", None),
117-
history_change_reason=getattr(instance, "changeReason", ""),
123+
history_user=getattr(instance, "_history_user", default_user),
124+
history_change_reason=getattr(
125+
instance, "changeReason", default_change_reason
126+
),
118127
history_type=history_type,
119128
**{
120129
field.attname: getattr(instance, field.attname)

simple_history/tests/tests/test_manager.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,55 @@ def test_bulk_history_create_with_change_reason(self):
126126
)
127127
)
128128

129+
def test_bulk_history_create_with_default_user(self):
130+
user = User.objects.create_user("tester", "[email protected]")
131+
132+
Poll.history.bulk_history_create(self.data, default_user=user)
133+
134+
self.assertTrue(
135+
all([history.history_user == user for history in Poll.history.all()])
136+
)
137+
138+
def test_bulk_history_create_with_default_change_reason(self):
139+
Poll.history.bulk_history_create(self.data, default_change_reason="test")
140+
141+
self.assertTrue(
142+
all(
143+
[
144+
history.history_change_reason == "test"
145+
for history in Poll.history.all()
146+
]
147+
)
148+
)
149+
150+
def test_bulk_history_create_history_user_overrides_default(self):
151+
user1 = User.objects.create_user("tester1", "[email protected]")
152+
user2 = User.objects.create_user("tester2", "[email protected]")
153+
154+
for data in self.data:
155+
data._history_user = user1
156+
157+
Poll.history.bulk_history_create(self.data, default_user=user2)
158+
159+
self.assertTrue(
160+
all([history.history_user == user1 for history in Poll.history.all()])
161+
)
162+
163+
def test_bulk_history_create_change_reason_overrides_default(self):
164+
for data in self.data:
165+
data.changeReason = "my_reason"
166+
167+
Poll.history.bulk_history_create(self.data, default_change_reason="test")
168+
169+
self.assertTrue(
170+
all(
171+
[
172+
history.history_change_reason == "my_reason"
173+
for history in Poll.history.all()
174+
]
175+
)
176+
)
177+
129178
def test_bulk_history_create_on_objs_without_ids(self):
130179
self.data = [
131180
Poll(question="Question 1", pub_date=datetime.now()),

simple_history/tests/tests/test_utils.py

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from unittest import skipIf
2+
3+
import django
4+
from django.contrib.auth import get_user_model
15
from django.db import IntegrityError
26
from django.test import TestCase, TransactionTestCase
37
from django.utils import timezone
@@ -11,7 +15,13 @@
1115
PollWithExcludeFields,
1216
Street,
1317
)
14-
from simple_history.utils import bulk_create_with_history, update_change_reason
18+
from simple_history.utils import (
19+
bulk_create_with_history,
20+
update_change_reason,
21+
bulk_update_with_history,
22+
)
23+
24+
User = get_user_model()
1525

1626

1727
class BulkCreateWithHistoryTestCase(TestCase):
@@ -37,6 +47,29 @@ def test_bulk_create_history(self):
3747
self.assertEqual(Poll.objects.count(), 5)
3848
self.assertEqual(Poll.history.count(), 5)
3949

50+
def test_bulk_create_history_with_default_user(self):
51+
user = User.objects.create_user("tester", "[email protected]")
52+
53+
bulk_create_with_history(self.data, Poll, default_user=user)
54+
55+
self.assertTrue(
56+
all([history.history_user == user for history in Poll.history.all()])
57+
)
58+
59+
def test_bulk_create_history_with_default_change_reason(self):
60+
bulk_create_with_history(
61+
self.data, Poll, default_change_reason="my change reason"
62+
)
63+
64+
self.assertTrue(
65+
all(
66+
[
67+
history.history_change_reason == "my change reason"
68+
for history in Poll.history.all()
69+
]
70+
)
71+
)
72+
4073
def test_bulk_create_history_num_queries_is_two(self):
4174
with self.assertNumQueries(2):
4275
bulk_create_with_history(self.data, Poll)
@@ -142,9 +175,95 @@ def test_bulk_create_no_ids_return(self, hist_manager_mock):
142175
result = bulk_create_with_history(objects, model)
143176
self.assertEqual(result, objects)
144177
hist_manager_mock().bulk_history_create.assert_called_with(
145-
objects, batch_size=None
178+
objects, batch_size=None, default_user=None, default_change_reason=None
179+
)
180+
181+
182+
@skipIf(django.VERSION < (2, 2,), reason="bulk_update does not exist before 2.2")
183+
class BulkUpdateWithHistoryTestCase(TestCase):
184+
def setUp(self):
185+
self.data = [
186+
Poll(id=1, question="Question 1", pub_date=timezone.now()),
187+
Poll(id=2, question="Question 2", pub_date=timezone.now()),
188+
Poll(id=3, question="Question 3", pub_date=timezone.now()),
189+
Poll(id=4, question="Question 4", pub_date=timezone.now()),
190+
Poll(id=5, question="Question 5", pub_date=timezone.now()),
191+
]
192+
bulk_create_with_history(self.data, Poll)
193+
194+
self.data[3].question = "Updated question"
195+
196+
def test_bulk_update_history(self):
197+
bulk_update_with_history(
198+
self.data, Poll, fields=["question"],
199+
)
200+
201+
self.assertEqual(Poll.objects.count(), 5)
202+
self.assertEqual(Poll.objects.get(id=4).question, "Updated question")
203+
self.assertEqual(Poll.history.count(), 10)
204+
self.assertEqual(Poll.history.filter(history_type="~").count(), 5)
205+
206+
def test_bulk_update_history_with_default_user(self):
207+
user = User.objects.create_user("tester", "[email protected]")
208+
209+
bulk_update_with_history(
210+
self.data, Poll, fields=["question"], default_user=user
211+
)
212+
213+
self.assertTrue(
214+
all(
215+
[
216+
history.history_user == user
217+
for history in Poll.history.filter(history_type="~")
218+
]
219+
)
220+
)
221+
222+
def test_bulk_update_history_with_default_change_reason(self):
223+
bulk_update_with_history(
224+
self.data,
225+
Poll,
226+
fields=["question"],
227+
default_change_reason="my change reason",
228+
)
229+
230+
self.assertTrue(
231+
all(
232+
[
233+
history.history_change_reason == "my change reason"
234+
for history in Poll.history.filter(history_type="~")
235+
]
236+
)
146237
)
147238

239+
def test_bulk_update_history_num_queries_is_two(self):
240+
with self.assertNumQueries(2):
241+
bulk_update_with_history(
242+
self.data, Poll, fields=["question"],
243+
)
244+
245+
def test_bulk_update_history_on_model_without_history_raises_error(self):
246+
self.data = [
247+
Place(id=1, name="Place 1"),
248+
Place(id=2, name="Place 2"),
249+
Place(id=3, name="Place 3"),
250+
]
251+
Place.objects.bulk_create(self.data)
252+
self.data[0].name = "test"
253+
254+
with self.assertRaises(NotHistoricalModelError):
255+
bulk_update_with_history(self.data, Place, fields=["name"])
256+
257+
def test_num_queries_when_batch_size_is_less_than_total(self):
258+
with self.assertNumQueries(6):
259+
bulk_update_with_history(self.data, Poll, fields=["question"], batch_size=2)
260+
261+
def test_bulk_update_history_with_batch_size(self):
262+
bulk_update_with_history(self.data, Poll, fields=["question"], batch_size=2)
263+
264+
self.assertEqual(Poll.objects.count(), 5)
265+
self.assertEqual(Poll.history.filter(history_type="~").count(), 5)
266+
148267

149268
class UpdateChangeReasonTestCase(TestCase):
150269
def test_update_change_reason_with_excluded_fields(self):

simple_history/utils.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ def get_history_model_for_model(model):
4242
return get_history_manager_for_model(model).model
4343

4444

45-
def bulk_create_with_history(objs, model, batch_size=None):
45+
def bulk_create_with_history(
46+
objs, model, batch_size=None, default_user=None, default_change_reason=None
47+
):
4648
"""
4749
Bulk create the objects specified by objs while also bulk creating
4850
their history (all in one transaction).
@@ -52,6 +54,10 @@ def bulk_create_with_history(objs, model, batch_size=None):
5254
:param objs: List of objs (not yet saved to the db) of type model
5355
:param model: Model class that should be created
5456
:param batch_size: Number of objects that should be created in each batch
57+
:param default_user: Optional user to specify as the history_user in each historical
58+
record
59+
:param default_change_reason: Optional change reason to specify as the change_reason
60+
in each historical record
5561
:return: List of objs with IDs
5662
"""
5763

@@ -61,7 +67,12 @@ def bulk_create_with_history(objs, model, batch_size=None):
6167
objs_with_id = model.objects.bulk_create(objs, batch_size=batch_size)
6268
if objs_with_id and objs_with_id[0].pk:
6369
second_transaction_required = False
64-
history_manager.bulk_history_create(objs_with_id, batch_size=batch_size)
70+
history_manager.bulk_history_create(
71+
objs_with_id,
72+
batch_size=batch_size,
73+
default_user=default_user,
74+
default_change_reason=default_change_reason,
75+
)
6576
if second_transaction_required:
6677
obj_list = []
6778
with transaction.atomic(savepoint=False):
@@ -70,19 +81,30 @@ def bulk_create_with_history(objs, model, batch_size=None):
7081
filter(lambda x: x[1] is not None, model_to_dict(obj).items())
7182
)
7283
obj_list += model.objects.filter(**attributes)
73-
history_manager.bulk_history_create(obj_list, batch_size=batch_size)
84+
history_manager.bulk_history_create(
85+
obj_list,
86+
batch_size=batch_size,
87+
default_user=default_user,
88+
default_change_reason=default_change_reason,
89+
)
7490
objs_with_id = obj_list
7591
return objs_with_id
7692

7793

78-
def bulk_update_with_history(objs, model, fields, batch_size=None):
94+
def bulk_update_with_history(
95+
objs, model, fields, batch_size=None, default_user=None, default_change_reason=None,
96+
):
7997
"""
8098
Bulk update the objects specified by objs while also bulk creating
8199
their history (all in one transaction).
82100
:param objs: List of objs of type model to be updated
83101
:param model: Model class that should be updated
84102
:param fields: The fields that are updated
85103
:param batch_size: Number of objects that should be updated in each batch
104+
:param default_user: Optional user to specify as the history_user in each historical
105+
record
106+
:param default_change_reason: Optional change reason to specify as the change_reason
107+
in each historical record
86108
"""
87109
if django.VERSION < (2, 2,):
88110
raise NotImplementedError(
@@ -91,5 +113,13 @@ def bulk_update_with_history(objs, model, fields, batch_size=None):
91113
)
92114
history_manager = get_history_manager_for_model(model)
93115
with transaction.atomic(savepoint=False):
94-
model.objects.bulk_update(objs, fields, batch_size=batch_size)
95-
history_manager.bulk_history_create(objs, batch_size=batch_size, update=True)
116+
model.objects.bulk_update(
117+
objs, fields, batch_size=batch_size,
118+
)
119+
history_manager.bulk_history_create(
120+
objs,
121+
batch_size=batch_size,
122+
update=True,
123+
default_user=default_user,
124+
default_change_reason=default_change_reason,
125+
)

0 commit comments

Comments
 (0)