Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Authors
- Renaud Perrin (`leminaw <https://github.com/leminaw>`_)
- Roberto Aguilar
- Rod Xavier Bondoc
- Rodrigo Nogueira (`rodrigobnogueira <https://github.com/rodrigobnogueira>`_)
- Ross Lote
- Ross Mechanic (`rossmechanic <https://github.com/rossmechanic>`_)
- Ross Rogers
Expand Down
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Changes
Unreleased
----------

- Added ``SIMPLE_HISTORY_CUSTOM_BASES`` setting to specify default base classes
for historical models (gh-1574)
- Added ``SIMPLE_HISTORY_CUSTOM_M2M_BASES`` setting to specify default base classes
for M2M historical models (gh-1574)


3.11.0 (2025-12-09)
-------------------
Expand Down
18 changes: 18 additions & 0 deletions docs/historical_model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,24 @@ To change the auto-generated HistoricalRecord models base class from
history = HistoricalRecords(bases=[RoutableModel])


You can also set a system-wide default for all HistoricalRecord base classes using
the ``SIMPLE_HISTORY_CUSTOM_BASES`` setting in your ``settings.py`` file:

.. code-block:: python

SIMPLE_HISTORY_CUSTOM_BASES = [RoutableModel, AuditModel]

This setting will be used for all ``HistoricalRecords`` instances that don't explicitly
specify a ``bases`` parameter. The explicit ``bases`` parameter always takes precedence
over this setting, allowing you to override the global default on a per-model basis.

For many-to-many historical models, you can use:

.. code-block:: python

SIMPLE_HISTORY_CUSTOM_M2M_BASES = [M2MRoutableModel]


Excluded Fields
--------------------------------

Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dynamic = [
"readme",
Expand Down
14 changes: 14 additions & 0 deletions simple_history/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,26 @@ def __init__(
if excluded_field_kwargs is None:
excluded_field_kwargs = {}
self.excluded_field_kwargs = excluded_field_kwargs

if bases == (models.Model,):
custom_bases = getattr(settings, "SIMPLE_HISTORY_CUSTOM_BASES", None)
if custom_bases is not None:
bases = custom_bases

try:
if isinstance(bases, str):
raise TypeError
self.bases = (HistoricalChanges,) + tuple(bases)
except TypeError:
raise TypeError("The `bases` option must be a list or a tuple.")

if m2m_bases == (models.Model,):
custom_m2m_bases = getattr(
settings, "SIMPLE_HISTORY_CUSTOM_M2M_BASES", None
)
if custom_m2m_bases is not None:
m2m_bases = custom_m2m_bases

try:
if isinstance(m2m_bases, str):
raise TypeError
Expand Down
38 changes: 38 additions & 0 deletions simple_history/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,44 @@ class ConcreteUtil(AbstractBase):
register(ConcreteUtil, bases=[AbstractBase])


# Test SIMPLE_HISTORY_CUSTOM_BASES setting
setattr(settings, "SIMPLE_HISTORY_CUSTOM_BASES", [IPAddressHistoricalModel])


class PollWithDefaultCustomBases(models.Model):
question = models.CharField(max_length=200)
history = HistoricalRecords()


class PollWithExplicitBasesOverride(models.Model):
question = models.CharField(max_length=200)
history = HistoricalRecords(bases=[SessionsHistoricalModel])


delattr(settings, "SIMPLE_HISTORY_CUSTOM_BASES")


# Test SIMPLE_HISTORY_CUSTOM_M2M_BASES setting
setattr(settings, "SIMPLE_HISTORY_CUSTOM_M2M_BASES", [IPAddressHistoricalModel])


class PollWithDefaultCustomM2MBases(models.Model):
question = models.CharField(max_length=200)
places = models.ManyToManyField("Place")
history = HistoricalRecords(m2m_fields=[places])


class PollWithExplicitM2MBasesOverride(models.Model):
question = models.CharField(max_length=200)
places = models.ManyToManyField("Place")
history = HistoricalRecords(
m2m_fields=[places], m2m_bases=[SessionsHistoricalModel]
)


delattr(settings, "SIMPLE_HISTORY_CUSTOM_M2M_BASES")


class MultiOneToOne(models.Model):
fk = models.ForeignKey(SecondLevelInheritedModel, on_delete=models.CASCADE)

Expand Down
65 changes: 65 additions & 0 deletions simple_history/tests/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,13 @@
PollChildRestaurantWithManyToMany,
PollInfo,
PollWithAlternativeManager,
PollWithDefaultCustomBases,
PollWithDefaultCustomM2MBases,
PollWithExcludedFieldsWithDefaults,
PollWithExcludedFKField,
PollWithExcludeFields,
PollWithExplicitBasesOverride,
PollWithExplicitM2MBasesOverride,
PollWithHistoricalIPAddress,
PollWithManyToMany,
PollWithManyToManyCustomHistoryID,
Expand Down Expand Up @@ -1476,6 +1480,67 @@ def test_invalid_bases(self):
for bases in invalid_bases:
self.assertRaises(TypeError, HistoricalRecords, bases=bases)

def test_custom_bases_from_settings(self):
"""Test that SIMPLE_HISTORY_CUSTOM_BASES setting is correctly applied."""
# PollWithDefaultCustomBases uses SIMPLE_HISTORY_CUSTOM_BASES
history_model = PollWithDefaultCustomBases.history.model
# Check that IPAddressHistoricalModel is in the MRO
self.assertIn(
"IPAddressHistoricalModel",
[cls.__name__ for cls in history_model.__mro__],
)
# Verify the historical model has ip_address field from IPAddressHistoricalModel
self.assertTrue(hasattr(history_model, "ip_address"))

def test_custom_bases_explicit_overrides_setting(self):
"""Test that explicit bases parameter overrides setting."""
# PollWithExplicitBasesOverride uses explicit bases parameter
# even though SIMPLE_HISTORY_CUSTOM_BASES was set to [IPAddressHistoricalModel]
history_model = PollWithExplicitBasesOverride.history.model
# Should have SessionsHistoricalModel, not IPAddressHistoricalModel
self.assertIn(
"SessionsHistoricalModel",
[cls.__name__ for cls in history_model.__mro__],
)
self.assertTrue(hasattr(history_model, "session"))
# Should NOT have ip_address from IPAddressHistoricalModel
self.assertFalse(hasattr(history_model, "ip_address"))

def test_custom_m2m_bases_from_settings(self):
"""Test that SIMPLE_HISTORY_CUSTOM_M2M_BASES setting is applied."""
# PollWithDefaultCustomM2MBases uses SIMPLE_HISTORY_CUSTOM_M2M_BASES
# Get the M2M historical model by its generated name
from django.apps import apps

m2m_history_model = apps.get_model(
"tests", "HistoricalPollWithDefaultCustomM2MBases_places"
)
# Check that IPAddressHistoricalModel is in the MRO
self.assertIn(
"IPAddressHistoricalModel",
[cls.__name__ for cls in m2m_history_model.__mro__],
)
# Verify the M2M historical model has ip_address field
self.assertTrue(hasattr(m2m_history_model, "ip_address"))

def test_custom_m2m_bases_explicit_overrides_setting(self):
"""Test that explicit m2m_bases parameter overrides setting."""
# PollWithExplicitM2MBasesOverride uses explicit m2m_bases
# even though SIMPLE_HISTORY_CUSTOM_M2M_BASES was set
from django.apps import apps

m2m_history_model = apps.get_model(
"tests", "HistoricalPollWithExplicitM2MBasesOverride_places"
)
# Should have SessionsHistoricalModel, not IPAddressHistoricalModel
self.assertIn(
"SessionsHistoricalModel",
[cls.__name__ for cls in m2m_history_model.__mro__],
)
self.assertTrue(hasattr(m2m_history_model, "session"))
# Should NOT have ip_address from IPAddressHistoricalModel
self.assertFalse(hasattr(m2m_history_model, "ip_address"))

def test_import_related(self):
field_object = HistoricalChoice._meta.get_field("poll")
related_model = field_object.remote_field.related_model
Expand Down