Skip to content

Commit b8c8c93

Browse files
committed
Add support for inheritance in m2m
1 parent 7042a64 commit b8c8c93

File tree

5 files changed

+104
-18
lines changed

5 files changed

+104
-18
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Authors
8080
- Klaas van Schelven
8181
- Kris Neuharth
8282
- Kyle Seever (`kseever <https://github.com/kseever>`_)
83+
- Léni Gauffier (`legau <https://github.com/legau>`_)
8384
- Leticia Portella
8485
- Lucas Wiman
8586
- Maciej "RooTer" Urbański

docs/historical_model.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,11 +461,15 @@ If you want to track many to many relationships, you need to define them explici
461461
class Poll(models.Model):
462462
question = models.CharField(max_length=200)
463463
categories = models.ManyToManyField(Category)
464-
history = HistoricalRecords(many_to_many=[categories])
464+
history = HistoricalRecords(m2m_fields=[categories])
465465
466466
This will create a historical intermediate model that tracks each relational change
467467
between `Poll` and `Category`.
468468

469+
You may also define these fields in a model attribute (by default on `_history_m2m_fields`).
470+
This is mainly used for inherited models. You can override the attribute name by setting
471+
your own `m2m_fields_model_field_name` argument on the `HistoricalRecord` instance.
472+
469473
You will see the many to many changes when diffing between two historical records:
470474

471475
.. code-block:: python

simple_history/models.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def __init__(
9494
no_db_index=list(),
9595
excluded_field_kwargs=None,
9696
m2m_fields=(),
97+
m2m_fields_model_field_name="_history_m2m_fields",
9798
):
9899
self.user_set_verbose_name = verbose_name
99100
self.user_set_verbose_name_plural = verbose_name_plural
@@ -114,6 +115,7 @@ def __init__(
114115
self.related_name = related_name
115116
self.use_base_model_db = use_base_model_db
116117
self.m2m_fields = m2m_fields
118+
self.m2m_fields_model_field_name = m2m_fields_model_field_name
117119

118120
if isinstance(no_db_index, str):
119121
no_db_index = [no_db_index]
@@ -189,7 +191,10 @@ def finalize(self, sender, **kwargs):
189191
# so the signal handlers can't use weak references.
190192
models.signals.post_save.connect(self.post_save, sender=sender, weak=False)
191193
models.signals.post_delete.connect(self.post_delete, sender=sender, weak=False)
192-
for field in self.m2m_fields:
194+
195+
m2m_fields = self.get_m2m_fields_from_model(sender)
196+
197+
for field in m2m_fields:
193198
m2m_changed.connect(
194199
partial(self.m2m_changed, attr=field.name),
195200
sender=field.remote_field.through,
@@ -200,13 +205,12 @@ def finalize(self, sender, **kwargs):
200205
setattr(sender, self.manager_name, descriptor)
201206
sender._meta.simple_history_manager_attribute = self.manager_name
202207

203-
for field in self.m2m_fields:
208+
for field in m2m_fields:
204209
m2m_model = self.create_history_m2m_model(
205210
history_model, field.remote_field.through
206211
)
207212
self.m2m_models[field] = m2m_model
208213

209-
module = importlib.import_module(self.module)
210214
setattr(module, m2m_model.__name__, m2m_model)
211215

212216
m2m_descriptor = HistoryDescriptor(m2m_model)
@@ -236,23 +240,12 @@ def get_history_model_name(self, model):
236240

237241
def create_history_m2m_model(self, model, through_model):
238242
attrs = {
239-
"__module__": self.module,
243+
"__module__": model.__module__,
240244
"__str__": lambda self: "{} as of {}".format(
241245
self._meta.verbose_name, self.history.history_date
242246
),
243247
}
244248

245-
app_module = "%s.models" % model._meta.app_label
246-
247-
if model.__module__ != self.module:
248-
# registered under different app
249-
attrs["__module__"] = self.module
250-
elif app_module != self.module:
251-
# Abuse an internal API because the app registry is loading.
252-
app = apps.app_configs[model._meta.app_label]
253-
models_module = app.name
254-
attrs["__module__"] = models_module
255-
256249
# Get the primary key to the history model this model will look up to
257250
attrs["m2m_history_id"] = self._get_history_id_field()
258251
attrs["history"] = models.ForeignKey(
@@ -285,7 +278,7 @@ def create_history_model(self, model, inherited):
285278
attrs = {
286279
"__module__": self.module,
287280
"_history_excluded_fields": self.excluded_fields,
288-
"_history_m2m_fields": self.m2m_fields,
281+
"_history_m2m_fields": self.get_m2m_fields_from_model(model),
289282
}
290283

291284
app_module = "%s.models" % model._meta.app_label
@@ -637,7 +630,7 @@ def m2m_changed(self, instance, action, attr, pk_set, reverse, **_):
637630
self.create_historical_record(instance, "~")
638631

639632
def create_historical_record_m2ms(self, history_instance, instance):
640-
for field in self.m2m_fields:
633+
for field in history_instance._history_m2m_fields:
641634
m2m_history_model = self.m2m_models[field]
642635
original_instance = history_instance.instance
643636
through_model = getattr(original_instance, field.name).through
@@ -721,6 +714,14 @@ def get_history_user(self, instance):
721714

722715
return self.get_user(instance=instance, request=request)
723716

717+
def get_m2m_fields_from_model(self, model):
718+
m2m_fields = set(self.m2m_fields)
719+
try:
720+
m2m_fields.update(getattr(model, self.m2m_fields_model_field_name))
721+
except AttributeError:
722+
pass
723+
return [getattr(model, field.name).field for field in m2m_fields]
724+
724725

725726
def transform_field(field):
726727
"""Customize field appropriately for use in historical model"""

simple_history/tests/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,32 @@ class PollWithSeveralManyToMany(models.Model):
127127
history = HistoricalRecords(m2m_fields=[places, restaurants, books])
128128

129129

130+
class PollParentWithManyToMany(models.Model):
131+
question = models.CharField(max_length=200)
132+
pub_date = models.DateTimeField("date published")
133+
places = models.ManyToManyField("Place")
134+
135+
history = HistoricalRecords(
136+
m2m_fields=[places],
137+
inherit=True,
138+
)
139+
140+
class Meta:
141+
abstract = True
142+
143+
144+
class PollChildBookWithManyToMany(PollParentWithManyToMany):
145+
books = models.ManyToManyField("Book", related_name="books_poll_child")
146+
_history_m2m_fields = [books]
147+
148+
149+
class PollChildRestaurantWithManyToMany(PollParentWithManyToMany):
150+
restaurants = models.ManyToManyField(
151+
"Restaurant", related_name="restaurants_poll_child"
152+
)
153+
_history_m2m_fields = [restaurants]
154+
155+
130156
class CustomAttrNameForeignKey(models.ForeignKey):
131157
def __init__(self, *args, **kwargs):
132158
self.attr_name = kwargs.pop("attr_name", None)

simple_history/tests/tests/test_models.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
Person,
8888
Place,
8989
Poll,
90+
PollChildBookWithManyToMany,
91+
PollChildRestaurantWithManyToMany,
9092
PollInfo,
9193
PollWithExcludedFieldsWithDefaults,
9294
PollWithExcludedFKField,
@@ -1736,6 +1738,58 @@ def test_separation(self):
17361738
self.assertEqual(add.places.all().count(), 0)
17371739

17381740

1741+
class InheritedManyToManyTest(TestCase):
1742+
def setUp(self):
1743+
self.model_book = PollChildBookWithManyToMany
1744+
self.model_rstr = PollChildRestaurantWithManyToMany
1745+
self.place = Place.objects.create(name="Home")
1746+
self.book = Book.objects.create(isbn="1234")
1747+
self.restaurant = Restaurant.objects.create(rating=1)
1748+
self.poll_book = self.model_book.objects.create(
1749+
question="what's up?", pub_date=today
1750+
)
1751+
self.poll_rstr = self.model_rstr.objects.create(
1752+
question="what's up?", pub_date=today
1753+
)
1754+
1755+
def test_separation(self):
1756+
self.assertEqual(self.poll_book.history.all().count(), 1)
1757+
self.poll_book.places.add(self.place)
1758+
self.poll_book.books.add(self.book)
1759+
self.assertEqual(self.poll_book.history.all().count(), 3)
1760+
1761+
self.assertEqual(self.poll_rstr.history.all().count(), 1)
1762+
self.poll_rstr.places.add(self.place)
1763+
self.poll_rstr.restaurants.add(self.restaurant)
1764+
self.assertEqual(self.poll_rstr.history.all().count(), 3)
1765+
1766+
book, place, add = self.poll_book.history.all()
1767+
1768+
self.assertEqual(book.books.all().count(), 1)
1769+
self.assertEqual(book.places.all().count(), 1)
1770+
self.assertEqual(book.books.first().book, self.book)
1771+
1772+
self.assertEqual(place.books.all().count(), 0)
1773+
self.assertEqual(place.places.all().count(), 1)
1774+
self.assertEqual(place.places.first().place, self.place)
1775+
1776+
self.assertEqual(add.books.all().count(), 0)
1777+
self.assertEqual(add.places.all().count(), 0)
1778+
1779+
restaurant, place, add = self.poll_rstr.history.all()
1780+
1781+
self.assertEqual(restaurant.restaurants.all().count(), 1)
1782+
self.assertEqual(restaurant.places.all().count(), 1)
1783+
self.assertEqual(restaurant.restaurants.first().restaurant, self.restaurant)
1784+
1785+
self.assertEqual(place.restaurants.all().count(), 0)
1786+
self.assertEqual(place.places.all().count(), 1)
1787+
self.assertEqual(place.places.first().place, self.place)
1788+
1789+
self.assertEqual(add.restaurants.all().count(), 0)
1790+
self.assertEqual(add.places.all().count(), 0)
1791+
1792+
17391793
class ManyToManyTest(TestCase):
17401794
def setUp(self):
17411795
self.model = PollWithManyToMany

0 commit comments

Comments
 (0)