From 1318d1c9c28a236f1311a9486af60fbd59084d81 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Tue, 13 Feb 2024 13:52:34 -0600 Subject: [PATCH 1/8] Support customizing the history manager and historical queryset classes. --- CHANGES.rst | 1 + docs/historical_model.rst | 62 +++++++++++++++++++++++ simple_history/manager.py | 20 +++++--- simple_history/models.py | 17 ++++++- simple_history/tests/models.py | 20 ++++++++ simple_history/tests/tests/test_models.py | 37 ++++++++++++++ 6 files changed, 147 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 049e39f75..611432a57 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,6 +16,7 @@ Unreleased version is lower than 4.2 (gh-1261) - Small performance optimization of the ``clean-duplicate_history`` command (gh-1015) - Support Simplified Chinese translation (gh-1281) +- Support custom History `Manager` and `QuerySet` classes (gh-1280) 3.4.0 (2023-08-18) ------------------ diff --git a/docs/historical_model.rst b/docs/historical_model.rst index 45739ec10..a149c4ea6 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -179,6 +179,68 @@ IMPORTANT: Setting `custom_model_name` to `lambda x:f'{x}'` is not permitted. An error will be generated and no history model created if they are the same. +Custom History Manager and Historical QuerySets +----------------------------------------------- + +To manipulate the History Manager or the Historical QuerySet, you can specify the +``history_manager`` and ``historical_queryset`` options. Tht values must be subclasses +of ``simple_history.manager.HistoryManager`` and +``simple_history.manager.HistoricalQuerySet`` respectively. + +Keep in mind, you can use either or both of these options. To understand the +difference between a `Manager` and QuerySet, see the Django `Manager documentation`_. + + +.. code-block:: python + from datetime import timedelta + from django.db import models + from django.utils import timezone + from simple_history.models import HistoricalRecords + from simple_history.manager import HistoryManager, HistoricalQuerySet + + class HistoryQuestionManager(HistoryManager): + def published(self): + return self.filter(pub_date__lte=timezone.now()) + + class HistoryQuestionQuerySet(HistoricalQuerySet): + def question_prefixed(self): + return self.filter(question__startswith='Question: ') + + class Question(models.Model): + pub_date = models.DateTimeField('date published') + history = HistoricalRecords( + history_manager=HistoryQuestionManager, + historical_queryset=HistoryQuestionQuerySet, + ) + + queryset = Question.history.published().question_prefixed() + + +To reuse a `QuerySet` from the model, see the following code example: + +.. code-block:: python + from datetime import timedelta + from django.db import models + from django.utils import timezone + from simple_history.models import HistoricalRecords + from simple_history.manager import HistoryManager, HistoricalQuerySet + + class QuestionQuerySet(HistoricalQuerySet): + def question_prefixed(self): + return self.filter(question__startswith='Question: ') + + + class HistoryQuestionQuerySet(QuestionQuerySet, HistoricalQuerySet): + """Redefine QuerySet with base class HistoricalQuerySet""" + + class Question(models.Model): + pub_date = models.DateTimeField('date published') + history = HistoricalRecords(historical_queryset=HistoryQuestionQuerySet) + manager = models.Manager.from_queryset(HistoryQuestionQuerySet)() + +.. _Manager documentation: https://docs.djangoproject.com/en/stable/topics/db/managers/ + + TextField as `history_change_reason` ------------------------------------ diff --git a/simple_history/manager.py b/simple_history/manager.py index dc1e75bbc..97d745281 100644 --- a/simple_history/manager.py +++ b/simple_history/manager.py @@ -119,14 +119,6 @@ def _instanceize(self): setattr(historic, "_as_of", self._as_of) -class HistoryDescriptor: - def __init__(self, model): - self.model = model - - def __get__(self, instance, owner): - return HistoryManager.from_queryset(HistoricalQuerySet)(self.model, instance) - - class HistoryManager(models.Manager): def __init__(self, model, instance=None): super().__init__() @@ -272,3 +264,15 @@ def bulk_history_create( return self.model.objects.bulk_create( historical_instances, batch_size=batch_size ) + + +class HistoryDescriptor: + def __init__(self, model, manager=HistoryManager, queryset=HistoricalQuerySet): + self.model = model + self.queryset_class = queryset + self.manager_class = manager + + def __get__(self, instance, owner): + return self.manager_class.from_queryset(self.queryset_class)( + self.model, instance + ) diff --git a/simple_history/models.py b/simple_history/models.py index 6dc4db9e8..a4b922710 100644 --- a/simple_history/models.py +++ b/simple_history/models.py @@ -31,7 +31,12 @@ from simple_history import utils from . import exceptions -from .manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoryDescriptor +from .manager import ( + SIMPLE_HISTORY_REVERSE_ATTR_NAME, + HistoricalQuerySet, + HistoryDescriptor, + HistoryManager, +) from .signals import ( post_create_historical_m2m_records, post_create_historical_record, @@ -100,6 +105,8 @@ def __init__( user_db_constraint=True, no_db_index=list(), excluded_field_kwargs=None, + history_manager=HistoryManager, + historical_queryset=HistoricalQuerySet, m2m_fields=(), m2m_fields_model_field_name="_history_m2m_fields", m2m_bases=(models.Model,), @@ -122,6 +129,8 @@ def __init__( self.user_setter = history_user_setter self.related_name = related_name self.use_base_model_db = use_base_model_db + self.history_manager = history_manager + self.historical_queryset = historical_queryset self.m2m_fields = m2m_fields self.m2m_fields_model_field_name = m2m_fields_model_field_name @@ -215,7 +224,11 @@ def finalize(self, sender, **kwargs): weak=False, ) - descriptor = HistoryDescriptor(history_model) + descriptor = HistoryDescriptor( + history_model, + manager=self.history_manager, + queryset=self.historical_queryset, + ) setattr(sender, self.manager_name, descriptor) sender._meta.simple_history_manager_attribute = self.manager_name diff --git a/simple_history/tests/models.py b/simple_history/tests/models.py index 99c6a2f87..f35b5cf6e 100644 --- a/simple_history/tests/models.py +++ b/simple_history/tests/models.py @@ -9,6 +9,7 @@ from django.urls import reverse from simple_history import register +from simple_history.manager import HistoricalQuerySet, HistoryManager from simple_history.models import HistoricalRecords, HistoricForeignKey from .custom_user.models import CustomUser as User @@ -155,6 +156,25 @@ class PollWithManyToManyCustomHistoryID(models.Model): ) +class PollQuerySet(HistoricalQuerySet): + def questions(self): + return self.filter(question__startswith="Question ") + + +class PollManager(HistoryManager): + def low_ids(self): + return self.filter(id__lte=3) + + +class PollWithQuerySetCustomizations(models.Model): + question = models.CharField(max_length=200) + pub_date = models.DateTimeField("date published") + + history = HistoricalRecords( + history_manager=PollManager, historical_queryset=PollQuerySet + ) + + class HistoricalRecordsWithExtraFieldM2M(HistoricalRecords): def get_extra_fields_m2m(self, model, through_model, fields): extra_fields = super().get_extra_fields_m2m(model, through_model, fields) diff --git a/simple_history/tests/tests/test_models.py b/simple_history/tests/tests/test_models.py index 484df73f9..d24cb1d2a 100644 --- a/simple_history/tests/tests/test_models.py +++ b/simple_history/tests/tests/test_models.py @@ -103,6 +103,7 @@ PollWithManyToManyCustomHistoryID, PollWithManyToManyWithIPAddress, PollWithNonEditableField, + PollWithQuerySetCustomizations, PollWithSelfManyToMany, PollWithSeveralManyToMany, Province, @@ -800,6 +801,42 @@ def test_history_with_unknown_field(self): with self.assertNumQueries(0): new_record.diff_against(old_record, excluded_fields=["unknown_field"]) + def test_history_with_custom_queryset(self): + PollWithQuerySetCustomizations.objects.create( + id=1, pub_date=today, question="Question 1" + ) + PollWithQuerySetCustomizations.objects.create( + id=2, pub_date=today, question="Low Id" + ) + PollWithQuerySetCustomizations.objects.create( + id=10, pub_date=today, question="Random" + ) + + self.assertEqual( + set( + PollWithQuerySetCustomizations.history.low_ids().values_list( + "question", flat=True + ) + ), + {"Question 1", "Low Id"}, + ) + self.assertEqual( + set( + PollWithQuerySetCustomizations.history.questions().values_list( + "question", flat=True + ) + ), + {"Question 1"}, + ) + self.assertEqual( + set( + PollWithQuerySetCustomizations.history.low_ids() + .questions() + .values_list("question", flat=True) + ), + {"Question 1"}, + ) + class GetPrevRecordAndNextRecordTestCase(TestCase): def assertRecordsMatch(self, record_a, record_b): From 56242d3e378057dd03ee5180a57f4e5a5582bba0 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Tue, 13 Feb 2024 13:52:53 -0600 Subject: [PATCH 2/8] Fix admin docs syntax. --- docs/admin.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin.rst b/docs/admin.rst index cbec184a1..1b34f92c4 100644 --- a/docs/admin.rst +++ b/docs/admin.rst @@ -84,7 +84,7 @@ When ``SIMPLE_HISTORY_REVERT_DISABLED`` is set to ``True``, the revert button is .. image:: screens/10_revert_disabled.png Enforcing history model permissions in Admin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To make the Django admin site evaluate history model permissions explicitly, update your settings with the following: From 9acde8065c857e7482f1a7376128ced19d7b64e1 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Tue, 13 Feb 2024 14:50:58 -0600 Subject: [PATCH 3/8] Fix docs code. This should be squashed. --- docs/historical_model.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/historical_model.rst b/docs/historical_model.rst index a149c4ea6..fdbe339a3 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -236,7 +236,7 @@ To reuse a `QuerySet` from the model, see the following code example: class Question(models.Model): pub_date = models.DateTimeField('date published') history = HistoricalRecords(historical_queryset=HistoryQuestionQuerySet) - manager = models.Manager.from_queryset(HistoryQuestionQuerySet)() + manager = models.Manager.from_queryset(QuestionQuerySet)() .. _Manager documentation: https://docs.djangoproject.com/en/stable/topics/db/managers/ From 21e2e7635dd99def00b9002c0b3158bd1fe8330a Mon Sep 17 00:00:00 2001 From: Anders <6058745+ddabble@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:16:52 +0100 Subject: [PATCH 4/8] Polished custom history manager+queryset docs --- docs/historical_model.rst | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/historical_model.rst b/docs/historical_model.rst index fdbe339a3..ff3c9703a 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -182,41 +182,46 @@ IMPORTANT: Setting `custom_model_name` to `lambda x:f'{x}'` is not permitted. Custom History Manager and Historical QuerySets ----------------------------------------------- -To manipulate the History Manager or the Historical QuerySet, you can specify the -``history_manager`` and ``historical_queryset`` options. Tht values must be subclasses +To manipulate the history manager or the historical ``QuerySet`` of +``HistoricalRecords``, you can specify the ``history_manager`` and +``historical_queryset`` options. The values must be subclasses of ``simple_history.manager.HistoryManager`` and -``simple_history.manager.HistoricalQuerySet`` respectively. +``simple_history.manager.HistoricalQuerySet``, respectively. Keep in mind, you can use either or both of these options. To understand the -difference between a `Manager` and QuerySet, see the Django `Manager documentation`_. - +difference between a ``Manager`` and a ``QuerySet``, +see `Django's Manager documentation`_. .. code-block:: python from datetime import timedelta from django.db import models from django.utils import timezone - from simple_history.models import HistoricalRecords from simple_history.manager import HistoryManager, HistoricalQuerySet + from simple_history.models import HistoricalRecords + class HistoryQuestionManager(HistoryManager): def published(self): return self.filter(pub_date__lte=timezone.now()) + class HistoryQuestionQuerySet(HistoricalQuerySet): def question_prefixed(self): - return self.filter(question__startswith='Question: ') + return self.filter(question__startswith="Question: ") + class Question(models.Model): - pub_date = models.DateTimeField('date published') + pub_date = models.DateTimeField("date published") history = HistoricalRecords( history_manager=HistoryQuestionManager, historical_queryset=HistoryQuestionQuerySet, ) + # This is now possible: queryset = Question.history.published().question_prefixed() -To reuse a `QuerySet` from the model, see the following code example: +To reuse a ``QuerySet`` from the model, see the following code example: .. code-block:: python from datetime import timedelta @@ -225,20 +230,22 @@ To reuse a `QuerySet` from the model, see the following code example: from simple_history.models import HistoricalRecords from simple_history.manager import HistoryManager, HistoricalQuerySet + class QuestionQuerySet(HistoricalQuerySet): def question_prefixed(self): - return self.filter(question__startswith='Question: ') + return self.filter(question__startswith="Question: ") class HistoryQuestionQuerySet(QuestionQuerySet, HistoricalQuerySet): - """Redefine QuerySet with base class HistoricalQuerySet""" + """Redefine ``QuerySet`` with base class ``HistoricalQuerySet``.""" + class Question(models.Model): - pub_date = models.DateTimeField('date published') + pub_date = models.DateTimeField("date published") history = HistoricalRecords(historical_queryset=HistoryQuestionQuerySet) manager = models.Manager.from_queryset(QuestionQuerySet)() -.. _Manager documentation: https://docs.djangoproject.com/en/stable/topics/db/managers/ +.. _Django's Manager documentation: https://docs.djangoproject.com/en/stable/topics/db/managers/ TextField as `history_change_reason` From 80fc667591b08bb0e91ae041238e585e15f65259 Mon Sep 17 00:00:00 2001 From: Anders <6058745+ddabble@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:32:22 +0100 Subject: [PATCH 5/8] 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. --- docs/historical_model.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/historical_model.rst b/docs/historical_model.rst index ff3c9703a..c97d46baf 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -193,6 +193,7 @@ difference between a ``Manager`` and a ``QuerySet``, see `Django's Manager documentation`_. .. code-block:: python + from datetime import timedelta from django.db import models from django.utils import timezone @@ -224,6 +225,7 @@ see `Django's Manager documentation`_. To reuse a ``QuerySet`` from the model, see the following code example: .. code-block:: python + from datetime import timedelta from django.db import models from django.utils import timezone From 266ab7797048b2d8223990d6d32955ad5c0972ce Mon Sep 17 00:00:00 2001 From: Anders <6058745+ddabble@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:39:57 +0100 Subject: [PATCH 6/8] Improved changelog format --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 611432a57..48268ee6e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,7 +16,7 @@ Unreleased version is lower than 4.2 (gh-1261) - Small performance optimization of the ``clean-duplicate_history`` command (gh-1015) - Support Simplified Chinese translation (gh-1281) -- Support custom History `Manager` and `QuerySet` classes (gh-1280) +- Support custom History ``Manager`` and ``QuerySet`` classes (gh-1280) 3.4.0 (2023-08-18) ------------------ From deb8135db28e852fdd903f8b02698a00163f56c1 Mon Sep 17 00:00:00 2001 From: Anders <6058745+ddabble@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:42:48 +0100 Subject: [PATCH 7/8] Polished custom history manager+queryset docs --- docs/historical_model.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/historical_model.rst b/docs/historical_model.rst index c97d46baf..53ef1e08f 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -182,7 +182,7 @@ IMPORTANT: Setting `custom_model_name` to `lambda x:f'{x}'` is not permitted. Custom History Manager and Historical QuerySets ----------------------------------------------- -To manipulate the history manager or the historical ``QuerySet`` of +To manipulate the history ``Manager`` or the historical ``QuerySet`` of ``HistoricalRecords``, you can specify the ``history_manager`` and ``historical_queryset`` options. The values must be subclasses of ``simple_history.manager.HistoryManager`` and From a5c0c8fc017ae8af18b5cd3970dae0dca6aff2f5 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Tue, 20 Feb 2024 07:44:33 -0600 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Anders <6058745+ddabble@users.noreply.github.com> --- docs/historical_model.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/historical_model.rst b/docs/historical_model.rst index 53ef1e08f..fbf931c4f 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -233,7 +233,7 @@ To reuse a ``QuerySet`` from the model, see the following code example: from simple_history.manager import HistoryManager, HistoricalQuerySet - class QuestionQuerySet(HistoricalQuerySet): + class QuestionQuerySet(models.QuerySet): def question_prefixed(self): return self.filter(question__startswith="Question: ") @@ -245,7 +245,7 @@ To reuse a ``QuerySet`` from the model, see the following code example: class Question(models.Model): pub_date = models.DateTimeField("date published") history = HistoricalRecords(historical_queryset=HistoryQuestionQuerySet) - manager = models.Manager.from_queryset(QuestionQuerySet)() + manager = QuestionQuerySet.as_manager() .. _Django's Manager documentation: https://docs.djangoproject.com/en/stable/topics/db/managers/