Skip to content

Commit

Permalink
Support customizing the history manager and historical queryset class…
Browse files Browse the repository at this point in the history
…es (jazzband#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>
  • Loading branch information
tim-schilling and ddabble authored Feb 24, 2024
1 parent ac44d22 commit c39ef2a
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Changes
Unreleased
----------

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

3.5.0 (2024-02-19)
------------------
Expand Down
2 changes: 1 addition & 1 deletion docs/admin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
71 changes: 71 additions & 0 deletions docs/historical_model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
------------------------------------

Expand Down
20 changes: 12 additions & 8 deletions simple_history/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand Down Expand Up @@ -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
)
17 changes: 15 additions & 2 deletions simple_history/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,),
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions simple_history/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions simple_history/tests/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
PollWithManyToManyCustomHistoryID,
PollWithManyToManyWithIPAddress,
PollWithNonEditableField,
PollWithQuerySetCustomizations,
PollWithSelfManyToMany,
PollWithSeveralManyToMany,
Province,
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit c39ef2a

Please sign in to comment.