Repository: jazzband/django-simple-history

Issue:
Saving history on batch_create
Currently, when using batch_create, django-simple-history doesn't save the history of the records saved in the `batch_create` call because no `post_save` signals are called. It would be great have a django-simple-history method for calling batch_create with history. Planning on working on this issue on the near future. If anyone has any suggestions for implementation, feel free to discuss here. 


Discussion:

Resolving patch:
diff --git a/simple_history/utils.py b/simple_history/utils.py
--- a/simple_history/utils.py
+++ b/simple_history/utils.py
@@ -1,3 +1,5 @@
+from django.db import transaction
+
 from simple_history.exceptions import NotHistoricalModelError
 
 
@@ -31,3 +33,23 @@ def get_history_manager_for_model(model):
 def get_history_model_for_model(model):
     """Return the history model for a given app model."""
     return get_history_manager_for_model(model).model
+
+
+def bulk_create_with_history(objs, model, batch_size=None):
+    """
+    Bulk create the objects specified by objs while also bulk creating
+    their history (all in one transaction).
+    :param objs: List of objs (not yet saved to the db) of type model
+    :param model: Model class that should be created
+    :param batch_size: Number of objects that should be created in each batch
+    :return: List of objs with IDs
+    """
+
+    history_manager = get_history_manager_for_model(model)
+
+    with transaction.atomic(savepoint=False):
+        objs_with_id = model.objects.bulk_create(objs, batch_size=batch_size)
+        history_manager.bulk_history_create(objs_with_id,
+                                            batch_size=batch_size)
+
+    return objs_with_id


Test patch:
diff --git a/simple_history/tests/tests/test_utils.py b/simple_history/tests/tests/test_utils.py
new file mode 100644
--- /dev/null
+++ b/simple_history/tests/tests/test_utils.py
@@ -0,0 +1,110 @@
+from django.db import IntegrityError
+from django.test import TestCase, TransactionTestCase
+from django.utils.timezone import now
+from mock import Mock, patch
+
+from simple_history.exceptions import NotHistoricalModelError
+from simple_history.tests.models import (
+    Document,
+    Place,
+    Poll,
+    PollWithExcludeFields
+)
+from simple_history.utils import bulk_create_with_history
+
+
+class BulkCreateWithHistoryTestCase(TestCase):
+    def setUp(self):
+        self.data = [
+            Poll(id=1, question='Question 1', pub_date=now()),
+            Poll(id=2, question='Question 2', pub_date=now()),
+            Poll(id=3, question='Question 3', pub_date=now()),
+            Poll(id=4, question='Question 4', pub_date=now()),
+            Poll(id=5, question='Question 5', pub_date=now()),
+        ]
+
+    def test_bulk_create_history(self):
+        bulk_create_with_history(self.data, Poll)
+
+        self.assertEqual(Poll.objects.count(), 5)
+        self.assertEqual(Poll.history.count(), 5)
+
+    def test_bulk_create_history_num_queries_is_two(self):
+        with self.assertNumQueries(2):
+            bulk_create_with_history(self.data, Poll)
+
+    def test_bulk_create_history_on_model_without_history_raises_error(self):
+        self.data = [
+            Place(id=1, name='Place 1'),
+            Place(id=2, name='Place 2'),
+            Place(id=3, name='Place 3'),
+        ]
+        with self.assertRaises(NotHistoricalModelError):
+            bulk_create_with_history(self.data, Place)
+
+    def test_num_queries_when_batch_size_is_less_than_total(self):
+        with self.assertNumQueries(6):
+            bulk_create_with_history(self.data, Poll, batch_size=2)
+
+    def test_bulk_create_history_with_batch_size(self):
+        bulk_create_with_history(self.data, Poll, batch_size=2)
+
+        self.assertEqual(Poll.objects.count(), 5)
+        self.assertEqual(Poll.history.count(), 5)
+
+    def test_bulk_create_works_with_excluded_fields(self):
+        bulk_create_with_history(self.data, PollWithExcludeFields)
+
+        self.assertEqual(Poll.objects.count(), 0)
+        self.assertEqual(Poll.history.count(), 0)
+
+        self.assertEqual(PollWithExcludeFields.objects.count(), 5)
+        self.assertEqual(PollWithExcludeFields.history.count(), 5)
+
+
+class BulkCreateWithHistoryTransactionTestCase(TransactionTestCase):
+    def setUp(self):
+        self.data = [
+            Poll(id=1, question='Question 1', pub_date=now()),
+            Poll(id=2, question='Question 2', pub_date=now()),
+            Poll(id=3, question='Question 3', pub_date=now()),
+            Poll(id=4, question='Question 4', pub_date=now()),
+            Poll(id=5, question='Question 5', pub_date=now()),
+        ]
+
+    @patch('simple_history.manager.HistoryManager.bulk_history_create',
+           Mock(side_effect=Exception))
+    def test_transaction_rolls_back_if_bulk_history_create_fails(self):
+        with self.assertRaises(Exception):
+            bulk_create_with_history(self.data, Poll)
+
+        self.assertEqual(Poll.objects.count(), 0)
+        self.assertEqual(Poll.history.count(), 0)
+
+    def test_bulk_create_history_on_objects_that_already_exist(self):
+        Poll.objects.bulk_create(self.data)
+
+        with self.assertRaises(IntegrityError):
+            bulk_create_with_history(self.data, Poll)
+
+        self.assertEqual(Poll.objects.count(), 5)
+        self.assertEqual(Poll.history.count(), 0)
+
+    def test_bulk_create_history_rolls_back_when_last_exists(self):
+        Poll.objects.create(id=5, question='Question 5', pub_date=now())
+
+        self.assertEqual(Poll.objects.count(), 1)
+        self.assertEqual(Poll.history.count(), 1)
+
+        with self.assertRaises(IntegrityError):
+            bulk_create_with_history(self.data, Poll, batch_size=1)
+
+        self.assertEqual(Poll.objects.count(), 1)
+        self.assertEqual(Poll.history.count(), 1)
+
+    def test_bulk_create_fails_with_wrong_model(self):
+        with self.assertRaises(AttributeError):
+            bulk_create_with_history(self.data, Document)
+
+        self.assertEqual(Poll.objects.count(), 0)
+        self.assertEqual(Poll.history.count(), 0)