Skip to content

Commit 329659b

Browse files
authored
Merge pull request #1248 from NoelJames/master
Batch with additional historical model fields
2 parents bde32e1 + c1ec612 commit 329659b

File tree

7 files changed

+133
-2
lines changed

7 files changed

+133
-2
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ Authors
107107
- Nathan Villagaray-Carski (`ncvc <https://github.com/ncvc>`_)
108108
- Nianpeng Li
109109
- Nick Träger
110+
- Noel James (`NoelJames <https://github.com/NoelJames>`_)
110111
- Phillip Marshall
111112
- Prakash Venkatraman (`dopatraman <https://github.com/dopatraman>`_)
112113
- Rajesh Pappula

CHANGES.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Unreleased
77
- Allow ``HistoricalRecords.m2m_fields`` as str (gh-1243)
88
- Fixed ``HistoryRequestMiddleware`` deleting non-existent
99
``HistoricalRecords.context.request`` in very specific circumstances (gh-1256)
10+
- Added ``custom_historical_attrs`` to ``bulk_create_with_history()`` and
11+
``bulk_update_with_history()`` for setting additional fields on custom history models
12+
(gh-1248)
13+
- Passing an empty list as the ``fields`` argument to ``bulk_update_with_history()`` is
14+
now allowed; history records will still be created (gh-1248)
15+
1016

1117
3.4.0 (2023-08-18)
1218
------------------

docs/common_issues.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,27 @@ You can also specify a default user or default change reason responsible for the
5555
>>> Poll.history.get(id=data[0].id).history_user == user
5656
True
5757
58+
If you're using `additional fields in historical models`_ and have custom fields to
59+
batch-create into the history, pass the optional dict argument ``custom_historical_attrs``
60+
containing the field names and values.
61+
A field ``session`` would be passed as ``custom_historical_attrs={'session': 'training'}``.
62+
63+
.. _additional fields in historical models: historical_model.html#adding-additional-fields-to-historical-models
64+
65+
.. code-block:: pycon
66+
67+
>>> from simple_history.tests.models import PollWithHistoricalSessionAttr
68+
>>> data = [
69+
PollWithHistoricalSessionAttr(id=x, question=f'Question {x}')
70+
for x in range(10)
71+
]
72+
>>> objs = bulk_create_with_history(
73+
data, PollWithHistoricalSessionAttr,
74+
custom_historical_attrs={'session': 'training'}
75+
)
76+
>>> data[0].history.get().session
77+
'training'
78+
5879
Bulk Updating a Model with History (New)
5980
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6081

@@ -88,6 +109,22 @@ default manager returns a filtered set), you can specify which manager to use wi
88109
>>> data = [PollWithAlternativeManager(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)]
89110
>>> objs = bulk_create_with_history(data, PollWithAlternativeManager, batch_size=500, manager=PollWithAlternativeManager.all_polls)
90111
112+
If you're using `additional fields in historical models`_ and have custom fields to
113+
batch-update into the history, pass the optional dict argument ``custom_historical_attrs``
114+
containing the field names and values.
115+
A field ``session`` would be passed as ``custom_historical_attrs={'session': 'jam'}``.
116+
117+
.. _additional fields in historical models: historical_model.html#adding-additional-fields-to-historical-models
118+
119+
.. code-block:: pycon
120+
121+
>>> bulk_update_with_history(
122+
data, PollWithHistoricalSessionAttr, [],
123+
custom_historical_attrs={'session': 'jam'}
124+
)
125+
>>> data[0].history.latest().session
126+
'jam'
127+
91128
QuerySet Updates with History (Updated in Django 2.2)
92129
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
93130
Unlike with ``bulk_create``, `queryset updates`_ perform an SQL update query on

simple_history/manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def bulk_history_create(
230230
default_user=None,
231231
default_change_reason="",
232232
default_date=None,
233+
custom_historical_attrs=None,
233234
):
234235
"""
235236
Bulk create the history for the objects specified by objs.
@@ -262,6 +263,7 @@ def bulk_history_create(
262263
field.attname: getattr(instance, field.attname)
263264
for field in self.model.tracked_fields
264265
},
266+
**(custom_historical_attrs or {}),
265267
)
266268
if hasattr(self.model, "history_relation"):
267269
row.history_relation_id = instance.pk

simple_history/tests/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ def get_absolute_url(self):
125125
return reverse("poll-detail", kwargs={"pk": self.pk})
126126

127127

128+
class SessionsHistoricalModel(models.Model):
129+
session = models.CharField(max_length=200, null=True, default=None)
130+
131+
class Meta:
132+
abstract = True
133+
134+
135+
class PollWithHistoricalSessionAttr(models.Model):
136+
question = models.CharField(max_length=200)
137+
history = HistoricalRecords(bases=[SessionsHistoricalModel])
138+
139+
128140
class PollWithManyToMany(models.Model):
129141
question = models.CharField(max_length=200)
130142
pub_date = models.DateTimeField("date published")

simple_history/tests/tests/test_utils.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
Poll,
1717
PollWithAlternativeManager,
1818
PollWithExcludeFields,
19+
PollWithHistoricalSessionAttr,
1920
PollWithUniqueQuestion,
2021
Street,
2122
)
2223
from simple_history.utils import (
2324
bulk_create_with_history,
2425
bulk_update_with_history,
26+
get_history_manager_for_model,
2527
update_change_reason,
2628
)
2729

@@ -288,6 +290,7 @@ def test_bulk_create_no_ids_return(self, hist_manager_mock):
288290
default_user=None,
289291
default_change_reason=None,
290292
default_date=None,
293+
custom_historical_attrs=None,
291294
)
292295

293296

@@ -509,6 +512,58 @@ def test_bulk_update_history_wrong_manager(self):
509512
)
510513

511514

515+
class CustomHistoricalAttrsTest(TestCase):
516+
def setUp(self):
517+
self.data = [
518+
PollWithHistoricalSessionAttr(id=x, question=f"Question {x}")
519+
for x in range(1, 6)
520+
]
521+
522+
def test_bulk_create_history_with_custom_model_attributes(self):
523+
bulk_create_with_history(
524+
self.data,
525+
PollWithHistoricalSessionAttr,
526+
custom_historical_attrs={"session": "jam"},
527+
)
528+
529+
self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 5)
530+
self.assertEqual(
531+
PollWithHistoricalSessionAttr.history.filter(session="jam").count(),
532+
5,
533+
)
534+
535+
def test_bulk_update_history_with_custom_model_attributes(self):
536+
bulk_create_with_history(
537+
self.data,
538+
PollWithHistoricalSessionAttr,
539+
custom_historical_attrs={"session": None},
540+
)
541+
bulk_update_with_history(
542+
self.data,
543+
PollWithHistoricalSessionAttr,
544+
fields=[],
545+
custom_historical_attrs={"session": "training"},
546+
)
547+
548+
self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 5)
549+
self.assertEqual(
550+
PollWithHistoricalSessionAttr.history.filter(session="training").count(),
551+
5,
552+
)
553+
554+
def test_bulk_manager_with_custom_model_attributes(self):
555+
history_manager = get_history_manager_for_model(PollWithHistoricalSessionAttr)
556+
history_manager.bulk_history_create(
557+
self.data, custom_historical_attrs={"session": "co-op"}
558+
)
559+
560+
self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 0)
561+
self.assertEqual(
562+
PollWithHistoricalSessionAttr.history.filter(session="co-op").count(),
563+
5,
564+
)
565+
566+
512567
class UpdateChangeReasonTestCase(TestCase):
513568
def test_update_change_reason_with_excluded_fields(self):
514569
poll = PollWithExcludeFields(

simple_history/utils.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def bulk_create_with_history(
6565
default_user=None,
6666
default_change_reason=None,
6767
default_date=None,
68+
custom_historical_attrs=None,
6869
):
6970
"""
7071
Bulk create the objects specified by objs while also bulk creating
@@ -81,6 +82,8 @@ def bulk_create_with_history(
8182
in each historical record
8283
:param default_date: Optional date to specify as the history_date in each historical
8384
record
85+
:param custom_historical_attrs: Optional dict of field `name`:`value` to specify
86+
values for custom fields
8487
:return: List of objs with IDs
8588
"""
8689
# Exclude ManyToManyFields because they end up as invalid kwargs to
@@ -106,6 +109,7 @@ def bulk_create_with_history(
106109
default_user=default_user,
107110
default_change_reason=default_change_reason,
108111
default_date=default_date,
112+
custom_historical_attrs=custom_historical_attrs,
109113
)
110114
if second_transaction_required:
111115
with transaction.atomic(savepoint=False):
@@ -143,6 +147,7 @@ def bulk_create_with_history(
143147
default_user=default_user,
144148
default_change_reason=default_change_reason,
145149
default_date=default_date,
150+
custom_historical_attrs=custom_historical_attrs,
146151
)
147152
objs_with_id = obj_list
148153
return objs_with_id
@@ -157,13 +162,15 @@ def bulk_update_with_history(
157162
default_change_reason=None,
158163
default_date=None,
159164
manager=None,
165+
custom_historical_attrs=None,
160166
):
161167
"""
162168
Bulk update the objects specified by objs while also bulk creating
163169
their history (all in one transaction).
164170
:param objs: List of objs of type model to be updated
165171
:param model: Model class that should be updated
166-
:param fields: The fields that are updated
172+
:param fields: The fields that are updated. If empty, no model objects will be
173+
changed, but history records will still be created.
167174
:param batch_size: Number of objects that should be updated in each batch
168175
:param default_user: Optional user to specify as the history_user in each historical
169176
record
@@ -173,6 +180,8 @@ def bulk_update_with_history(
173180
record
174181
:param manager: Optional model manager to use for the model instead of the default
175182
manager
183+
:param custom_historical_attrs: Optional dict of field `name`:`value` to specify
184+
values for custom fields
176185
:return: The number of model rows updated, not including any history objects
177186
"""
178187
history_manager = get_history_manager_for_model(model)
@@ -181,14 +190,23 @@ def bulk_update_with_history(
181190
raise AlternativeManagerError("The given manager does not belong to the model.")
182191

183192
with transaction.atomic(savepoint=False):
184-
rows_updated = model_manager.bulk_update(objs, fields, batch_size=batch_size)
193+
if not fields:
194+
# Allow not passing any fields if the user wants to bulk-create history
195+
# records - e.g. with `custom_historical_attrs` provided
196+
# (Calling `bulk_update()` with no fields would have raised an error)
197+
rows_updated = 0
198+
else:
199+
rows_updated = model_manager.bulk_update(
200+
objs, fields, batch_size=batch_size
201+
)
185202
history_manager.bulk_history_create(
186203
objs,
187204
batch_size=batch_size,
188205
update=True,
189206
default_user=default_user,
190207
default_change_reason=default_change_reason,
191208
default_date=default_date,
209+
custom_historical_attrs=custom_historical_attrs,
192210
)
193211
return rows_updated
194212

0 commit comments

Comments
 (0)