Repository: jazzband/django-simple-history

Issue:
`bulk_update_with_history` to support alternative model managers
**Problem Statement**
I have some models where the default model manager (`objects`) doesn't include all objects (it doesn't include "inactive"/deleted objects). However, `bulk_update_with_history` assumes that the model manager to use is `objects` which means it can never update those objects which are not included.

**Describe the solution you'd like**
A way to pass an alternative model manager.

**Describe alternatives you've considered**
Two options I can think of:

- Pass optional model manager (backwards compatible):
```
def bulk_update_with_history(
    objs, model, fields, batch_size=None, default_user=None, default_change_reason=None, manager=None,
):
    history_manager = get_history_manager_for_model(model)
    with transaction.atomic(savepoint=False):
        model_manager = manager or model.objects
        model_manager.bulk_update(
            objs, fields, batch_size=batch_size,
        )
        history_manager.bulk_history_create(
            objs,
            batch_size=batch_size,
            update=True,
            default_user=default_user,
            default_change_reason=default_change_reason,
        )
```
- Pass manager instead of model (breaking):
```
def bulk_update_with_history(
    objs, model_manager, fields, batch_size=None, default_user=None, default_change_reason=None,
):
    history_manager = get_history_manager_for_model(model_manager.model)
    with transaction.atomic(savepoint=False):
        model_manager.bulk_update(
            objs, fields, batch_size=batch_size,
        )
        history_manager.bulk_history_create(
            objs,
            batch_size=batch_size,
            update=True,
            default_user=default_user,
            default_change_reason=default_change_reason,
        )
```


Discussion:
Hm. Good catch. I don't want to introduce a breaking change, so I'm more comfortable with the first solution with validation that the manager's model is the model that was passed in. Happy to approve a PR if you want to add it @georgek 

Resolving patch:
diff --git a/simple_history/exceptions.py b/simple_history/exceptions.py
--- a/simple_history/exceptions.py
+++ b/simple_history/exceptions.py
@@ -19,3 +19,9 @@ class RelatedNameConflictError(Exception):
     """Related name conflicting with history manager"""
 
     pass
+
+
+class AlternativeManagerError(Exception):
+    """Manager does not belong to model"""
+
+    pass
diff --git a/simple_history/utils.py b/simple_history/utils.py
--- a/simple_history/utils.py
+++ b/simple_history/utils.py
@@ -5,7 +5,7 @@
 from django.db.models import ManyToManyField
 from django.forms.models import model_to_dict
 
-from simple_history.exceptions import NotHistoricalModelError
+from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError
 
 
 def update_change_reason(instance, reason):
@@ -78,9 +78,11 @@ def bulk_create_with_history(
         if isinstance(field, ManyToManyField)
     ]
     history_manager = get_history_manager_for_model(model)
+    model_manager = model._default_manager
+
     second_transaction_required = True
     with transaction.atomic(savepoint=False):
-        objs_with_id = model.objects.bulk_create(objs, batch_size=batch_size)
+        objs_with_id = model_manager.bulk_create(objs, batch_size=batch_size)
         if objs_with_id and objs_with_id[0].pk:
             second_transaction_required = False
             history_manager.bulk_history_create(
@@ -100,7 +102,7 @@ def bulk_create_with_history(
                         model_to_dict(obj, exclude=exclude_fields).items(),
                     )
                 )
-                obj_list += model.objects.filter(**attributes)
+                obj_list += model_manager.filter(**attributes)
             history_manager.bulk_history_create(
                 obj_list,
                 batch_size=batch_size,
@@ -120,6 +122,7 @@ def bulk_update_with_history(
     default_user=None,
     default_change_reason=None,
     default_date=None,
+    manager=None,
 ):
     """
     Bulk update the objects specified by objs while also bulk creating
@@ -134,6 +137,8 @@ def bulk_update_with_history(
         in each historical record
     :param default_date: Optional date to specify as the history_date in each historical
         record
+    :param manager: Optional model manager to use for the model instead of the default
+        manager
     """
     if django.VERSION < (2, 2,):
         raise NotImplementedError(
@@ -141,8 +146,12 @@ def bulk_update_with_history(
             "Django versions 2.2 and later"
         )
     history_manager = get_history_manager_for_model(model)
+    model_manager = manager or model._default_manager
+    if model_manager.model is not model:
+        raise AlternativeManagerError("The given manager does not belong to the model.")
+
     with transaction.atomic(savepoint=False):
-        model.objects.bulk_update(objs, fields, batch_size=batch_size)
+        model_manager.bulk_update(objs, fields, batch_size=batch_size)
         history_manager.bulk_history_create(
             objs,
             batch_size=batch_size,


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
@@ -63,6 +63,21 @@ class PollWithExcludedFKField(models.Model):
     history = HistoricalRecords(excluded_fields=["place"])
 
 
+class AlternativePollManager(models.Manager):
+    def get_queryset(self):
+        return super(AlternativePollManager, self).get_queryset().exclude(id=1)
+
+
+class PollWithAlternativeManager(models.Model):
+    some_objects = AlternativePollManager()
+    all_objects = models.Manager()
+
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField("date published")
+
+    history = HistoricalRecords()
+
+
 class IPAddressHistoricalModel(models.Model):
     ip_address = models.GenericIPAddressField()
 
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
@@ -8,12 +8,13 @@
 from django.utils import timezone
 from mock import Mock, patch
 
-from simple_history.exceptions import NotHistoricalModelError
+from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError
 from simple_history.tests.models import (
     BulkCreateManyToManyModel,
     Document,
     Place,
     Poll,
+    PollWithAlternativeManager,
     PollWithExcludeFields,
     Street,
 )
@@ -42,6 +43,23 @@ def setUp(self):
             PollWithExcludeFields(id=4, question="Question 4", pub_date=timezone.now()),
             PollWithExcludeFields(id=5, question="Question 5", pub_date=timezone.now()),
         ]
+        self.data_with_alternative_manager = [
+            PollWithAlternativeManager(
+                id=1, question="Question 1", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=2, question="Question 2", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=3, question="Question 3", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=4, question="Question 4", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=5, question="Question 5", pub_date=timezone.now()
+            ),
+        ]
 
     def test_bulk_create_history(self):
         bulk_create_with_history(self.data, Poll)
@@ -49,6 +67,14 @@ def test_bulk_create_history(self):
         self.assertEqual(Poll.objects.count(), 5)
         self.assertEqual(Poll.history.count(), 5)
 
+    def test_bulk_create_history_alternative_manager(self):
+        bulk_create_with_history(
+            self.data, PollWithAlternativeManager,
+        )
+
+        self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5)
+        self.assertEqual(PollWithAlternativeManager.history.count(), 5)
+
     def test_bulk_create_history_with_default_user(self):
         user = User.objects.create_user("tester", "tester@example.com")
 
@@ -177,7 +203,7 @@ def test_bulk_create_fails_with_wrong_model(self):
     def test_bulk_create_no_ids_return(self, hist_manager_mock):
         objects = [Place(id=1, name="Place 1")]
         model = Mock(
-            objects=Mock(
+            _default_manager=Mock(
                 bulk_create=Mock(return_value=[Place(name="Place 1")]),
                 filter=Mock(return_value=objects),
             ),
@@ -311,6 +337,78 @@ def test_bulk_update_history_with_batch_size(self):
         self.assertEqual(Poll.history.filter(history_type="~").count(), 5)
 
 
+@skipIf(django.VERSION < (2, 2,), reason="bulk_update does not exist before 2.2")
+class BulkUpdateWithHistoryAlternativeManagersTestCase(TestCase):
+    def setUp(self):
+        self.data = [
+            PollWithAlternativeManager(
+                id=1, question="Question 1", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=2, question="Question 2", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=3, question="Question 3", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=4, question="Question 4", pub_date=timezone.now()
+            ),
+            PollWithAlternativeManager(
+                id=5, question="Question 5", pub_date=timezone.now()
+            ),
+        ]
+        bulk_create_with_history(
+            self.data, PollWithAlternativeManager,
+        )
+
+    def test_bulk_update_history_default_manager(self):
+        self.data[3].question = "Updated question"
+
+        bulk_update_with_history(
+            self.data, PollWithAlternativeManager, fields=["question"],
+        )
+
+        self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5)
+        self.assertEqual(
+            PollWithAlternativeManager.all_objects.get(id=4).question,
+            "Updated question",
+        )
+        self.assertEqual(PollWithAlternativeManager.history.count(), 10)
+        self.assertEqual(
+            PollWithAlternativeManager.history.filter(history_type="~").count(), 5
+        )
+
+    def test_bulk_update_history_other_manager(self):
+        # filtered by default manager
+        self.data[0].question = "Updated question"
+
+        bulk_update_with_history(
+            self.data,
+            PollWithAlternativeManager,
+            fields=["question"],
+            manager=PollWithAlternativeManager.all_objects,
+        )
+
+        self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5)
+        self.assertEqual(
+            PollWithAlternativeManager.all_objects.get(id=1).question,
+            "Updated question",
+        )
+        self.assertEqual(PollWithAlternativeManager.history.count(), 10)
+        self.assertEqual(
+            PollWithAlternativeManager.history.filter(history_type="~").count(), 5
+        )
+
+    def test_bulk_update_history_wrong_manager(self):
+        with self.assertRaises(AlternativeManagerError):
+            bulk_update_with_history(
+                self.data,
+                PollWithAlternativeManager,
+                fields=["question"],
+                manager=Poll.objects,
+            )
+
+
 class UpdateChangeReasonTestCase(TestCase):
     def test_update_change_reason_with_excluded_fields(self):
         poll = PollWithExcludeFields(