Skip to content

Commit 6c33fa0

Browse files
committed
Merge pull request #112 from treyhunner/historical-abstract-base
Allow historical tracking to be set on abstract bases
2 parents 56279dc + 71d3bd6 commit 6c33fa0

File tree

7 files changed

+179
-20
lines changed

7 files changed

+179
-20
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Changes
22
=======
33

4+
tip (unreleased)
5+
----------------
6+
- History tracking can be inherited by passing `inherit=True`. (gh-63)
7+
48
1.7.0 (2015-12-02)
59
------------------
610
- Add ability to list history in admin when the object instance is deleted. (gh-72)

docs/advanced.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,35 @@ third-party apps you don't have control over. Here's an example of using
6565
register(User)
6666
6767
68+
Allow tracking to be inherited
69+
---------------------------------
70+
71+
By default history tracking is only added for the model that is passed
72+
to ``register()`` or has the ``HistoricalRecords`` descriptor. By
73+
passing ``inherit=True`` to either way of registering you can change
74+
that behavior so that any child model inheriting from it will have
75+
historical tracking as well. Be careful though, in cases where a model
76+
can be tracked more than once, ``MultipleRegistrationsError`` will be
77+
raised.
78+
79+
.. code-block:: python
80+
81+
from django.contrib.auth.models import User
82+
from django.db import models
83+
from simple_history import register
84+
from simple_history.models import HistoricalRecords
85+
86+
# register() example
87+
register(User, inherit=True)
88+
89+
# HistoricalRecords example
90+
class Poll(models.Model):
91+
history = HistoricalRecords(inherit=True)
92+
93+
Both ``User`` and ``Poll`` in the example above will cause any model
94+
inheriting from them to have historical tracking as well.
95+
96+
6897
.. recording_user:
6998
7099
Recording Which User Changed a Model

simple_history/__init__.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ def register(
2121
`HistoricalManager` instance directly to `model`.
2222
"""
2323
from . import models
24-
if model._meta.db_table not in models.registered_models:
25-
if records_class is None:
26-
records_class = models.HistoricalRecords
27-
records = records_class(**records_config)
28-
records.manager_name = manager_name
29-
records.table_name = table_name
30-
records.module = app and ("%s.models" % app) or model.__module__
31-
records.add_extra_methods(model)
32-
records.finalize(model)
33-
models.registered_models[model._meta.db_table] = model
24+
25+
if records_class is None:
26+
records_class = models.HistoricalRecords
27+
28+
records = records_class(**records_config)
29+
records.manager_name = manager_name
30+
records.table_name = table_name
31+
records.module = app and ("%s.models" % app) or model.__module__
32+
records.add_extra_methods(model)
33+
records.finalize(model)
34+
models.registered_models[model._meta.db_table] = model

simple_history/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
django-simple-history exceptions and warnings classes.
3+
"""
4+
5+
class MultipleRegistrationsError(Exception):
6+
"""The model has been registered to have history tracking more than once"""
7+
pass

simple_history/models.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
add_introspection_rules(
2727
[], ["^simple_history.models.CustomForeignKeyField"])
2828

29+
from . import exceptions
2930
from .manager import HistoryDescriptor
3031

3132
registered_models = {}
@@ -35,10 +36,11 @@ class HistoricalRecords(object):
3536
thread = threading.local()
3637

3738
def __init__(self, verbose_name=None, bases=(models.Model,),
38-
user_related_name='+', table_name=None):
39+
user_related_name='+', table_name=None, inherit=False):
3940
self.user_set_verbose_name = verbose_name
4041
self.user_related_name = user_related_name
4142
self.table_name = table_name
43+
self.inherit = inherit
4244
try:
4345
if isinstance(bases, six.string_types):
4446
raise TypeError
@@ -49,7 +51,8 @@ def __init__(self, verbose_name=None, bases=(models.Model,),
4951
def contribute_to_class(self, cls, name):
5052
self.manager_name = name
5153
self.module = cls.__module__
52-
models.signals.class_prepared.connect(self.finalize, sender=cls)
54+
self.cls = cls
55+
models.signals.class_prepared.connect(self.finalize, weak=False)
5356
self.add_extra_methods(cls)
5457

5558
def add_extra_methods(self, cls):
@@ -69,6 +72,19 @@ def save_without_historical_record(self, *args, **kwargs):
6972
save_without_historical_record)
7073

7174
def finalize(self, sender, **kwargs):
75+
try:
76+
hint_class = self.cls
77+
except AttributeError: # called via `register`
78+
pass
79+
else:
80+
if hint_class is not sender: # set in concrete
81+
if not (self.inherit and issubclass(sender, hint_class)): # set in abstract
82+
return
83+
if hasattr(sender._meta, 'simple_history_manager_attribute'):
84+
raise exceptions.MultipleRegistrationsError('{}.{} registered multiple times for history tracking.'.format(
85+
sender._meta.app_label,
86+
sender._meta.object_name,
87+
))
7288
history_model = self.create_history_model(sender)
7389
module = importlib.import_module(self.module)
7490
setattr(module, history_model.__name__, history_model)

simple_history/tests/models.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,37 @@ class ContactRegister(models.Model):
277277
email = models.EmailField(max_length=255, unique=True)
278278

279279
register(ContactRegister, table_name='contacts_register_history')
280+
281+
282+
###############################################################################
283+
#
284+
# Inheritance examples
285+
#
286+
###############################################################################
287+
288+
class TrackedAbstractBaseA(models.Model):
289+
history = HistoricalRecords(inherit=True)
290+
291+
class Meta:
292+
abstract = True
293+
294+
295+
class TrackedAbstractBaseB(models.Model):
296+
history_b = HistoricalRecords(inherit=True)
297+
298+
class Meta:
299+
abstract = True
300+
301+
302+
class UntrackedAbstractBase(models.Model):
303+
304+
class Meta:
305+
abstract = True
306+
307+
308+
class TrackedConcreteBase(models.Model):
309+
history = HistoricalRecords(inherit=True)
310+
311+
312+
class UntrackedConcreteBase(models.Model):
313+
pass

simple_history/tests/tests/test_models.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@
1010
from django.test import TestCase
1111
from django.core.files.base import ContentFile
1212

13+
from simple_history import exceptions, register
1314
from simple_history.models import HistoricalRecords, convert_auto_field
14-
from simple_history import register
1515
from ..models import (
1616
AdminProfile, Bookcase, MultiOneToOne, Poll, Choice, Voter, Restaurant,
1717
Person, FileModel, Document, Book, HistoricalPoll, Library, State,
1818
AbstractBase, ConcreteAttr, ConcreteUtil, SelfFK, Temperature, WaterLevel,
1919
ExternalModel1, ExternalModel3, UnicodeVerboseName, HistoricalChoice,
2020
HistoricalState, HistoricalCustomFKError, Series, SeriesWork, PollInfo,
2121
UserAccessorDefault, UserAccessorOverride, Employee, Country, Province,
22-
City, Contact, ContactRegister
22+
City, Contact, ContactRegister,
23+
TrackedAbstractBaseA, TrackedAbstractBaseB, UntrackedAbstractBase,
24+
TrackedConcreteBase, UntrackedConcreteBase,
2325
)
2426
from ..external.models import ExternalModel2, ExternalModel4
2527

@@ -332,12 +334,8 @@ def test_register_separate_app(self):
332334
self.assertEqual(len(user.histories.all()), 1)
333335

334336
def test_reregister(self):
335-
register(Restaurant, manager_name='again')
336-
register(User, manager_name='again')
337-
self.assertTrue(hasattr(Restaurant, 'updates'))
338-
self.assertFalse(hasattr(Restaurant, 'again'))
339-
self.assertTrue(hasattr(User, 'histories'))
340-
self.assertFalse(hasattr(User, 'again'))
337+
with self.assertRaises(exceptions.MultipleRegistrationsError):
338+
register(Restaurant, manager_name='again')
341339

342340
def test_register_custome_records(self):
343341
self.assertEqual(len(Voter.history.all()), 0)
@@ -783,3 +781,73 @@ def test_custom_table_name_from_register(self):
783781
self.get_table_name(ContactRegister.history),
784782
'contacts_register_history',
785783
)
784+
785+
786+
class TestTrackingInheritance(TestCase):
787+
788+
def test_tracked_abstract_base(self):
789+
class TrackedWithAbstractBase(TrackedAbstractBaseA):
790+
pass
791+
792+
self.assertEqual(
793+
[f.attname for f in TrackedWithAbstractBase.history.model._meta.fields],
794+
['id', 'history_id', 'history_date', 'history_user_id', 'history_type'],
795+
)
796+
797+
def test_tracked_concrete_base(self):
798+
class TrackedWithConcreteBase(TrackedConcreteBase):
799+
pass
800+
801+
self.assertEqual(
802+
[f.attname for f in TrackedWithConcreteBase.history.model._meta.fields],
803+
['id', 'trackedconcretebase_ptr_id', 'history_id', 'history_date', 'history_user_id', 'history_type'],
804+
)
805+
806+
def test_multiple_tracked_bases(self):
807+
with self.assertRaises(exceptions.MultipleRegistrationsError):
808+
class TrackedWithMultipleAbstractBases(TrackedAbstractBaseA, TrackedAbstractBaseB):
809+
pass
810+
811+
def test_tracked_abstract_and_untracked_concrete_base(self):
812+
class TrackedWithTrackedAbstractAndUntrackedConcreteBase(TrackedAbstractBaseA, UntrackedConcreteBase):
813+
pass
814+
815+
self.assertEqual(
816+
[f.attname for f in TrackedWithTrackedAbstractAndUntrackedConcreteBase.history.model._meta.fields],
817+
['id', 'untrackedconcretebase_ptr_id', 'history_id', 'history_date', 'history_user_id', 'history_type'],
818+
)
819+
820+
def test_indirect_tracked_abstract_base(self):
821+
class BaseTrackedWithIndirectTrackedAbstractBase(TrackedAbstractBaseA):
822+
pass
823+
824+
class TrackedWithIndirectTrackedAbstractBase(BaseTrackedWithIndirectTrackedAbstractBase):
825+
pass
826+
827+
self.assertEqual(
828+
[f.attname for f in TrackedWithIndirectTrackedAbstractBase.history.model._meta.fields],
829+
[
830+
'id', 'basetrackedwithindirecttrackedabstractbase_ptr_id',
831+
'history_id', 'history_date', 'history_user_id', 'history_type'],
832+
)
833+
834+
def test_indirect_tracked_concrete_base(self):
835+
class BaseTrackedWithIndirectTrackedConcreteBase(TrackedAbstractBaseA):
836+
pass
837+
838+
class TrackedWithIndirectTrackedConcreteBase(BaseTrackedWithIndirectTrackedConcreteBase):
839+
pass
840+
841+
self.assertEqual(
842+
[f.attname for f in TrackedWithIndirectTrackedConcreteBase.history.model._meta.fields],
843+
[
844+
'id', 'basetrackedwithindirecttrackedconcretebase_ptr_id',
845+
'history_id', 'history_date', 'history_user_id', 'history_type'],
846+
)
847+
848+
def test_registering_with_tracked_abstract_base(self):
849+
class TrackedWithAbstractBaseToRegister(TrackedAbstractBaseA):
850+
pass
851+
852+
with self.assertRaises(exceptions.MultipleRegistrationsError):
853+
register(TrackedWithAbstractBaseToRegister)

0 commit comments

Comments
 (0)