From c39ef2ab03677cffb965dc3176715c28f1ef61a3 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Sat, 24 Feb 2024 12:46:42 -0600 Subject: [PATCH] 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> --- CHANGES.rst | 1 + docs/admin.rst | 2 +- docs/historical_model.rst | 71 +++++++++++++++++++++++ simple_history/manager.py | 20 ++++--- simple_history/models.py | 17 +++++- simple_history/tests/models.py | 20 +++++++ simple_history/tests/tests/test_models.py | 37 ++++++++++++ 7 files changed, 157 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 30c9432b0..653c6951e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changes Unreleased ---------- +- Support custom History ``Manager`` and ``QuerySet`` classes (gh-1280) 3.5.0 (2024-02-19) ------------------ 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: diff --git a/docs/historical_model.rst b/docs/historical_model.rst index 45739ec10..fbf931c4f 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -179,6 +179,77 @@ 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`` 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. + +Keep in mind, you can use either or both of these options. To understand the +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.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: ") + + + class Question(models.Model): + 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: + +.. 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(models.QuerySet): + 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 = QuestionQuerySet.as_manager() + +.. _Django's 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):