Repository: jazzband/django-simple-history

Issue:
Batch support for additional historical model fields
**Problem Statement**

Adding [additional fields to historical models](https://django-simple-history.readthedocs.io/en/latest/historical_model.html#adding-additional-fields-to-historical-models) has been a useful approach to capturing some necessary data for me. However, I also have some operations that require `bulk_update_with_history` and `bulk_create_with_history` but these functions do not support these customized field value.   


**Describe the solution you'd like**

I would suggest allowing kwargs to be passed to `HistoryManager.bulk_history_create`, `utils.bulk_create_with_history` and `utils.bulk_update_with_history`. 

So that when a keyword argument name is prefixed with "custom_" it is intended for custom history model attributes. 

In `HistoryManager.bulk_history_create`  add `**kwargs` to the method params then after the "row" has been initialized add something like 

```python
    custom = {
        key.replace('custom_', ''): kwargs[key]
        for key in kwargs if key.startswith('custom_')
    }

    if custom:
        for name in custom:
            setattr(row, name, custom[name])
```                     

adding `**kwargs` to  `utils.bulk_create_with_history` and `utils.bulk_update_with_history` params then passing it on to the usages of `HistoryManager.bulk_history_create` works well.

Usage

```python
  bulk_create_with_history(
      data,
      Poll,
      batch_size=500,
      default_user=user,
      custom_ip_address=ip_address
  )
```




Discussion:

Resolving patch:
diff --git a/simple_history/manager.py b/simple_history/manager.py
--- a/simple_history/manager.py
+++ b/simple_history/manager.py
@@ -230,6 +230,7 @@ def bulk_history_create(
         default_user=None,
         default_change_reason="",
         default_date=None,
+        custom_historical_attrs=None,
     ):
         """
         Bulk create the history for the objects specified by objs.
@@ -262,6 +263,7 @@ def bulk_history_create(
                     field.attname: getattr(instance, field.attname)
                     for field in self.model.tracked_fields
                 },
+                **(custom_historical_attrs or {}),
             )
             if hasattr(self.model, "history_relation"):
                 row.history_relation_id = instance.pk
diff --git a/simple_history/utils.py b/simple_history/utils.py
--- a/simple_history/utils.py
+++ b/simple_history/utils.py
@@ -65,6 +65,7 @@ def bulk_create_with_history(
     default_user=None,
     default_change_reason=None,
     default_date=None,
+    custom_historical_attrs=None,
 ):
     """
     Bulk create the objects specified by objs while also bulk creating
@@ -81,6 +82,8 @@ def bulk_create_with_history(
         in each historical record
     :param default_date: Optional date to specify as the history_date in each historical
         record
+    :param custom_historical_attrs: Optional dict of field `name`:`value` to specify
+        values for custom fields
     :return: List of objs with IDs
     """
     # Exclude ManyToManyFields because they end up as invalid kwargs to
@@ -106,6 +109,7 @@ def bulk_create_with_history(
                 default_user=default_user,
                 default_change_reason=default_change_reason,
                 default_date=default_date,
+                custom_historical_attrs=custom_historical_attrs,
             )
     if second_transaction_required:
         with transaction.atomic(savepoint=False):
@@ -143,6 +147,7 @@ def bulk_create_with_history(
                 default_user=default_user,
                 default_change_reason=default_change_reason,
                 default_date=default_date,
+                custom_historical_attrs=custom_historical_attrs,
             )
         objs_with_id = obj_list
     return objs_with_id
@@ -157,13 +162,15 @@ def bulk_update_with_history(
     default_change_reason=None,
     default_date=None,
     manager=None,
+    custom_historical_attrs=None,
 ):
     """
     Bulk update the objects specified by objs while also bulk creating
     their history (all in one transaction).
     :param objs: List of objs of type model to be updated
     :param model: Model class that should be updated
-    :param fields: The fields that are updated
+    :param fields: The fields that are updated. If empty, no model objects will be
+        changed, but history records will still be created.
     :param batch_size: Number of objects that should be updated in each batch
     :param default_user: Optional user to specify as the history_user in each historical
         record
@@ -173,6 +180,8 @@ def bulk_update_with_history(
         record
     :param manager: Optional model manager to use for the model instead of the default
         manager
+    :param custom_historical_attrs: Optional dict of field `name`:`value` to specify
+        values for custom fields
     :return: The number of model rows updated, not including any history objects
     """
     history_manager = get_history_manager_for_model(model)
@@ -181,7 +190,15 @@ def bulk_update_with_history(
         raise AlternativeManagerError("The given manager does not belong to the model.")
 
     with transaction.atomic(savepoint=False):
-        rows_updated = model_manager.bulk_update(objs, fields, batch_size=batch_size)
+        if not fields:
+            # Allow not passing any fields if the user wants to bulk-create history
+            # records - e.g. with `custom_historical_attrs` provided
+            # (Calling `bulk_update()` with no fields would have raised an error)
+            rows_updated = 0
+        else:
+            rows_updated = model_manager.bulk_update(
+                objs, fields, batch_size=batch_size
+            )
         history_manager.bulk_history_create(
             objs,
             batch_size=batch_size,
@@ -189,6 +206,7 @@ def bulk_update_with_history(
             default_user=default_user,
             default_change_reason=default_change_reason,
             default_date=default_date,
+            custom_historical_attrs=custom_historical_attrs,
         )
     return rows_updated
 


Test patch:
diff --git a/simple_history/tests/models.py b/simple_history/tests/models.py
--- a/simple_history/tests/models.py
+++ b/simple_history/tests/models.py
@@ -125,6 +125,18 @@ def get_absolute_url(self):
         return reverse("poll-detail", kwargs={"pk": self.pk})
 
 
+class SessionsHistoricalModel(models.Model):
+    session = models.CharField(max_length=200, null=True, default=None)
+
+    class Meta:
+        abstract = True
+
+
+class PollWithHistoricalSessionAttr(models.Model):
+    question = models.CharField(max_length=200)
+    history = HistoricalRecords(bases=[SessionsHistoricalModel])
+
+
 class PollWithManyToMany(models.Model):
     question = models.CharField(max_length=200)
     pub_date = models.DateTimeField("date published")
diff --git a/simple_history/tests/tests/test_utils.py b/simple_history/tests/tests/test_utils.py
--- a/simple_history/tests/tests/test_utils.py
+++ b/simple_history/tests/tests/test_utils.py
@@ -16,12 +16,14 @@
     Poll,
     PollWithAlternativeManager,
     PollWithExcludeFields,
+    PollWithHistoricalSessionAttr,
     PollWithUniqueQuestion,
     Street,
 )
 from simple_history.utils import (
     bulk_create_with_history,
     bulk_update_with_history,
+    get_history_manager_for_model,
     update_change_reason,
 )
 
@@ -288,6 +290,7 @@ def test_bulk_create_no_ids_return(self, hist_manager_mock):
             default_user=None,
             default_change_reason=None,
             default_date=None,
+            custom_historical_attrs=None,
         )
 
 
@@ -509,6 +512,58 @@ def test_bulk_update_history_wrong_manager(self):
             )
 
 
+class CustomHistoricalAttrsTest(TestCase):
+    def setUp(self):
+        self.data = [
+            PollWithHistoricalSessionAttr(id=x, question=f"Question {x}")
+            for x in range(1, 6)
+        ]
+
+    def test_bulk_create_history_with_custom_model_attributes(self):
+        bulk_create_with_history(
+            self.data,
+            PollWithHistoricalSessionAttr,
+            custom_historical_attrs={"session": "jam"},
+        )
+
+        self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 5)
+        self.assertEqual(
+            PollWithHistoricalSessionAttr.history.filter(session="jam").count(),
+            5,
+        )
+
+    def test_bulk_update_history_with_custom_model_attributes(self):
+        bulk_create_with_history(
+            self.data,
+            PollWithHistoricalSessionAttr,
+            custom_historical_attrs={"session": None},
+        )
+        bulk_update_with_history(
+            self.data,
+            PollWithHistoricalSessionAttr,
+            fields=[],
+            custom_historical_attrs={"session": "training"},
+        )
+
+        self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 5)
+        self.assertEqual(
+            PollWithHistoricalSessionAttr.history.filter(session="training").count(),
+            5,
+        )
+
+    def test_bulk_manager_with_custom_model_attributes(self):
+        history_manager = get_history_manager_for_model(PollWithHistoricalSessionAttr)
+        history_manager.bulk_history_create(
+            self.data, custom_historical_attrs={"session": "co-op"}
+        )
+
+        self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 0)
+        self.assertEqual(
+            PollWithHistoricalSessionAttr.history.filter(session="co-op").count(),
+            5,
+        )
+
+
 class UpdateChangeReasonTestCase(TestCase):
     def test_update_change_reason_with_excluded_fields(self):
         poll = PollWithExcludeFields(