Skip to content

Commit c39ef2a

Browse files
Support customizing the history manager and historical queryset classes (#1306)
* Support customizing the history manager and historical queryset classes. * Fix admin docs syntax. * Fix docs code. This should be squashed. * Polished custom history manager+queryset docs * Fixed code blocks not rendering in docs See the rendered result at https://django-simple-history--1306.org.readthedocs.build/en/1306/historical_model.html#custom-history-manager-and-historical-querysets. * Improved changelog format * Polished custom history manager+queryset docs * Apply suggestions from code review Co-authored-by: Anders <6058745+ddabble@users.noreply.github.com> --------- Co-authored-by: Anders <6058745+ddabble@users.noreply.github.com>
1 parent ac44d22 commit c39ef2a

File tree

7 files changed

+157
-11
lines changed

7 files changed

+157
-11
lines changed

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

7+
- Support custom History ``Manager`` and ``QuerySet`` classes (gh-1280)
78

89
3.5.0 (2024-02-19)
910
------------------

docs/admin.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ When ``SIMPLE_HISTORY_REVERT_DISABLED`` is set to ``True``, the revert button is
8484
.. image:: screens/10_revert_disabled.png
8585

8686
Enforcing history model permissions in Admin
87-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
87+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
8888

8989
To make the Django admin site evaluate history model permissions explicitly,
9090
update your settings with the following:

docs/historical_model.rst

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,77 @@ IMPORTANT: Setting `custom_model_name` to `lambda x:f'{x}'` is not permitted.
179179
An error will be generated and no history model created if they are the same.
180180

181181

182+
Custom History Manager and Historical QuerySets
183+
-----------------------------------------------
184+
185+
To manipulate the history ``Manager`` or the historical ``QuerySet`` of
186+
``HistoricalRecords``, you can specify the ``history_manager`` and
187+
``historical_queryset`` options. The values must be subclasses
188+
of ``simple_history.manager.HistoryManager`` and
189+
``simple_history.manager.HistoricalQuerySet``, respectively.
190+
191+
Keep in mind, you can use either or both of these options. To understand the
192+
difference between a ``Manager`` and a ``QuerySet``,
193+
see `Django's Manager documentation`_.
194+
195+
.. code-block:: python
196+
197+
from datetime import timedelta
198+
from django.db import models
199+
from django.utils import timezone
200+
from simple_history.manager import HistoryManager, HistoricalQuerySet
201+
from simple_history.models import HistoricalRecords
202+
203+
204+
class HistoryQuestionManager(HistoryManager):
205+
def published(self):
206+
return self.filter(pub_date__lte=timezone.now())
207+
208+
209+
class HistoryQuestionQuerySet(HistoricalQuerySet):
210+
def question_prefixed(self):
211+
return self.filter(question__startswith="Question: ")
212+
213+
214+
class Question(models.Model):
215+
pub_date = models.DateTimeField("date published")
216+
history = HistoricalRecords(
217+
history_manager=HistoryQuestionManager,
218+
historical_queryset=HistoryQuestionQuerySet,
219+
)
220+
221+
# This is now possible:
222+
queryset = Question.history.published().question_prefixed()
223+
224+
225+
To reuse a ``QuerySet`` from the model, see the following code example:
226+
227+
.. code-block:: python
228+
229+
from datetime import timedelta
230+
from django.db import models
231+
from django.utils import timezone
232+
from simple_history.models import HistoricalRecords
233+
from simple_history.manager import HistoryManager, HistoricalQuerySet
234+
235+
236+
class QuestionQuerySet(models.QuerySet):
237+
def question_prefixed(self):
238+
return self.filter(question__startswith="Question: ")
239+
240+
241+
class HistoryQuestionQuerySet(QuestionQuerySet, HistoricalQuerySet):
242+
"""Redefine ``QuerySet`` with base class ``HistoricalQuerySet``."""
243+
244+
245+
class Question(models.Model):
246+
pub_date = models.DateTimeField("date published")
247+
history = HistoricalRecords(historical_queryset=HistoryQuestionQuerySet)
248+
manager = QuestionQuerySet.as_manager()
249+
250+
.. _Django's Manager documentation: https://docs.djangoproject.com/en/stable/topics/db/managers/
251+
252+
182253
TextField as `history_change_reason`
183254
------------------------------------
184255

simple_history/manager.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,6 @@ def _instanceize(self):
119119
setattr(historic, "_as_of", self._as_of)
120120

121121

122-
class HistoryDescriptor:
123-
def __init__(self, model):
124-
self.model = model
125-
126-
def __get__(self, instance, owner):
127-
return HistoryManager.from_queryset(HistoricalQuerySet)(self.model, instance)
128-
129-
130122
class HistoryManager(models.Manager):
131123
def __init__(self, model, instance=None):
132124
super().__init__()
@@ -272,3 +264,15 @@ def bulk_history_create(
272264
return self.model.objects.bulk_create(
273265
historical_instances, batch_size=batch_size
274266
)
267+
268+
269+
class HistoryDescriptor:
270+
def __init__(self, model, manager=HistoryManager, queryset=HistoricalQuerySet):
271+
self.model = model
272+
self.queryset_class = queryset
273+
self.manager_class = manager
274+
275+
def __get__(self, instance, owner):
276+
return self.manager_class.from_queryset(self.queryset_class)(
277+
self.model, instance
278+
)

simple_history/models.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@
3131
from simple_history import utils
3232

3333
from . import exceptions
34-
from .manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoryDescriptor
34+
from .manager import (
35+
SIMPLE_HISTORY_REVERSE_ATTR_NAME,
36+
HistoricalQuerySet,
37+
HistoryDescriptor,
38+
HistoryManager,
39+
)
3540
from .signals import (
3641
post_create_historical_m2m_records,
3742
post_create_historical_record,
@@ -100,6 +105,8 @@ def __init__(
100105
user_db_constraint=True,
101106
no_db_index=list(),
102107
excluded_field_kwargs=None,
108+
history_manager=HistoryManager,
109+
historical_queryset=HistoricalQuerySet,
103110
m2m_fields=(),
104111
m2m_fields_model_field_name="_history_m2m_fields",
105112
m2m_bases=(models.Model,),
@@ -122,6 +129,8 @@ def __init__(
122129
self.user_setter = history_user_setter
123130
self.related_name = related_name
124131
self.use_base_model_db = use_base_model_db
132+
self.history_manager = history_manager
133+
self.historical_queryset = historical_queryset
125134
self.m2m_fields = m2m_fields
126135
self.m2m_fields_model_field_name = m2m_fields_model_field_name
127136

@@ -215,7 +224,11 @@ def finalize(self, sender, **kwargs):
215224
weak=False,
216225
)
217226

218-
descriptor = HistoryDescriptor(history_model)
227+
descriptor = HistoryDescriptor(
228+
history_model,
229+
manager=self.history_manager,
230+
queryset=self.historical_queryset,
231+
)
219232
setattr(sender, self.manager_name, descriptor)
220233
sender._meta.simple_history_manager_attribute = self.manager_name
221234

simple_history/tests/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.urls import reverse
1010

1111
from simple_history import register
12+
from simple_history.manager import HistoricalQuerySet, HistoryManager
1213
from simple_history.models import HistoricalRecords, HistoricForeignKey
1314

1415
from .custom_user.models import CustomUser as User
@@ -155,6 +156,25 @@ class PollWithManyToManyCustomHistoryID(models.Model):
155156
)
156157

157158

159+
class PollQuerySet(HistoricalQuerySet):
160+
def questions(self):
161+
return self.filter(question__startswith="Question ")
162+
163+
164+
class PollManager(HistoryManager):
165+
def low_ids(self):
166+
return self.filter(id__lte=3)
167+
168+
169+
class PollWithQuerySetCustomizations(models.Model):
170+
question = models.CharField(max_length=200)
171+
pub_date = models.DateTimeField("date published")
172+
173+
history = HistoricalRecords(
174+
history_manager=PollManager, historical_queryset=PollQuerySet
175+
)
176+
177+
158178
class HistoricalRecordsWithExtraFieldM2M(HistoricalRecords):
159179
def get_extra_fields_m2m(self, model, through_model, fields):
160180
extra_fields = super().get_extra_fields_m2m(model, through_model, fields)

simple_history/tests/tests/test_models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
PollWithManyToManyCustomHistoryID,
104104
PollWithManyToManyWithIPAddress,
105105
PollWithNonEditableField,
106+
PollWithQuerySetCustomizations,
106107
PollWithSelfManyToMany,
107108
PollWithSeveralManyToMany,
108109
Province,
@@ -800,6 +801,42 @@ def test_history_with_unknown_field(self):
800801
with self.assertNumQueries(0):
801802
new_record.diff_against(old_record, excluded_fields=["unknown_field"])
802803

804+
def test_history_with_custom_queryset(self):
805+
PollWithQuerySetCustomizations.objects.create(
806+
id=1, pub_date=today, question="Question 1"
807+
)
808+
PollWithQuerySetCustomizations.objects.create(
809+
id=2, pub_date=today, question="Low Id"
810+
)
811+
PollWithQuerySetCustomizations.objects.create(
812+
id=10, pub_date=today, question="Random"
813+
)
814+
815+
self.assertEqual(
816+
set(
817+
PollWithQuerySetCustomizations.history.low_ids().values_list(
818+
"question", flat=True
819+
)
820+
),
821+
{"Question 1", "Low Id"},
822+
)
823+
self.assertEqual(
824+
set(
825+
PollWithQuerySetCustomizations.history.questions().values_list(
826+
"question", flat=True
827+
)
828+
),
829+
{"Question 1"},
830+
)
831+
self.assertEqual(
832+
set(
833+
PollWithQuerySetCustomizations.history.low_ids()
834+
.questions()
835+
.values_list("question", flat=True)
836+
),
837+
{"Question 1"},
838+
)
839+
803840

804841
class GetPrevRecordAndNextRecordTestCase(TestCase):
805842
def assertRecordsMatch(self, record_a, record_b):

0 commit comments

Comments
 (0)