Skip to content

Commit a7dd57f

Browse files
committed
#1266 resolving through tables defined as strings on m2m relations
1 parent 7fdebc8 commit a7dd57f

File tree

5 files changed

+94
-19
lines changed

5 files changed

+94
-19
lines changed

AUTHORS.rst

+1
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ Authors
144144
- `DanialErfanian <https://github.com/DanialErfanian>`_
145145
- `Sridhar Marella <https://github.com/sridhar562345>`_
146146
- `Mattia Fantoni <https://github.com/MattFanto>`_
147+
- `Trent Holliday <https://github.com/trumpet2012>`_
147148

148149
Background
149150
==========

CHANGES.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Changes
33

44
Unreleased
55
----------
6-
6+
- Added support for m2m fields that specify through tables as strings
77
- Made ``skip_history_when_saving`` work when creating an object - not just when
88
updating an object (gh-1262)
99
- Improved performance of the ``latest_of_each()`` history manager method (gh-1360)

simple_history/models.py

+25-18
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from django.db import models
1616
from django.db.models import ManyToManyField
1717
from django.db.models.fields.proxy import OrderWrt
18-
from django.db.models.fields.related import ForeignKey
18+
from django.db.models.fields.related import ForeignKey, lazy_related_operation
1919
from django.db.models.fields.related_descriptors import (
2020
ForwardManyToOneDescriptor,
2121
ReverseManyToOneDescriptor,
@@ -84,6 +84,8 @@ class HistoricalRecords:
8484
DEFAULT_MODEL_NAME_PREFIX = "Historical"
8585

8686
thread = context = LocalContext() # retain thread for backwards compatibility
87+
# Key is the m2m field and value is a tuple where first entry is the historical m2m
88+
# model and second is the through model
8789
m2m_models = {}
8890

8991
def __init__(
@@ -222,13 +224,6 @@ def finalize(self, sender, **kwargs):
222224

223225
m2m_fields = self.get_m2m_fields_from_model(sender)
224226

225-
for field in m2m_fields:
226-
m2m_changed.connect(
227-
partial(self.m2m_changed, attr=field.name),
228-
sender=field.remote_field.through,
229-
weak=False,
230-
)
231-
232227
descriptor = HistoryDescriptor(
233228
history_model,
234229
manager=self.history_manager,
@@ -238,15 +233,29 @@ def finalize(self, sender, **kwargs):
238233
sender._meta.simple_history_manager_attribute = self.manager_name
239234

240235
for field in m2m_fields:
241-
m2m_model = self.create_history_m2m_model(
242-
history_model, field.remote_field.through
243-
)
244-
self.m2m_models[field] = m2m_model
245236

246-
setattr(module, m2m_model.__name__, m2m_model)
237+
def resolve_through_model(history_model, through_model):
238+
m2m_changed.connect(
239+
partial(self.m2m_changed, attr=field.name),
240+
sender=through_model,
241+
weak=False,
242+
)
243+
m2m_model = self.create_history_m2m_model(history_model, through_model)
244+
# Save the created history model and the resolved through model together
245+
# for reference later
246+
self.m2m_models[field] = (m2m_model, through_model)
247+
248+
setattr(module, m2m_model.__name__, m2m_model)
247249

248-
m2m_descriptor = HistoryDescriptor(m2m_model)
249-
setattr(history_model, field.name, m2m_descriptor)
250+
m2m_descriptor = HistoryDescriptor(m2m_model)
251+
setattr(history_model, field.name, m2m_descriptor)
252+
253+
# Lazily generate the historical m2m models for the fields when all of the
254+
# associated models have been fully loaded. This handles resolving through
255+
# models referenced as strings. This is how django m2m fields handle this.
256+
lazy_related_operation(
257+
resolve_through_model, history_model, field.remote_field.through
258+
)
250259

251260
def get_history_model_name(self, model):
252261
if not self.custom_model_name:
@@ -685,9 +694,7 @@ def m2m_changed(self, instance, action, attr, pk_set, reverse, **_):
685694

686695
def create_historical_record_m2ms(self, history_instance, instance):
687696
for field in history_instance._history_m2m_fields:
688-
m2m_history_model = self.m2m_models[field]
689-
original_instance = history_instance.instance
690-
through_model = getattr(original_instance, field.name).through
697+
m2m_history_model, through_model = self.m2m_models[field]
691698
through_model_field_names = [f.name for f in through_model._meta.fields]
692699
through_model_fk_field_names = [
693700
f.name for f in through_model._meta.fields if isinstance(f, ForeignKey)

simple_history/tests/models.py

+12
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,18 @@ class PollWithSelfManyToMany(models.Model):
237237
history = HistoricalRecords(m2m_fields=[relations])
238238

239239

240+
class PollWithManyToManyThroughString(models.Model):
241+
books = models.ManyToManyField("Book", through="PollBookThroughTable")
242+
history = HistoricalRecords(m2m_fields=[books])
243+
244+
245+
class PollBookThroughTable(models.Model):
246+
book = models.ForeignKey("Book", on_delete=models.CASCADE)
247+
poll = models.ForeignKey(
248+
"PollWithManyToManyThroughString", on_delete=models.CASCADE
249+
)
250+
251+
240252
class CustomAttrNameForeignKey(models.ForeignKey):
241253
def __init__(self, *args, **kwargs):
242254
self.attr_name = kwargs.pop("attr_name", None)

simple_history/tests/tests/test_models.py

+55
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
PollWithHistoricalIPAddress,
9797
PollWithManyToMany,
9898
PollWithManyToManyCustomHistoryID,
99+
PollWithManyToManyThroughString,
99100
PollWithManyToManyWithIPAddress,
100101
PollWithNonEditableField,
101102
PollWithQuerySetCustomizations,
@@ -2515,6 +2516,60 @@ def test_diff_against(self):
25152516
self.assertEqual(delta, expected_delta)
25162517

25172518

2519+
class ManyToManyThroughStringTest(TestCase):
2520+
def setUp(self):
2521+
self.model = PollWithManyToManyThroughString
2522+
self.history_model = self.model.history.model
2523+
self.poll = self.model.objects.create()
2524+
self.book = Book.objects.create(isbn="1234")
2525+
2526+
def assertDatetimesEqual(self, time1, time2):
2527+
self.assertAlmostEqual(time1, time2, delta=timedelta(seconds=2))
2528+
2529+
def assertRecordValues(self, record, klass, values_dict):
2530+
for key, value in values_dict.items():
2531+
self.assertEqual(getattr(record, key), value)
2532+
self.assertEqual(record.history_object.__class__, klass)
2533+
for key, value in values_dict.items():
2534+
if key not in ["history_type", "history_change_reason"]:
2535+
self.assertEqual(getattr(record.history_object, key), value)
2536+
2537+
def test_create(self):
2538+
# There should be 1 history record for our poll, the create from setUp
2539+
self.assertEqual(self.poll.history.all().count(), 1)
2540+
2541+
# The created history row should be normal and correct
2542+
(record,) = self.poll.history.all()
2543+
self.assertRecordValues(
2544+
record,
2545+
self.model,
2546+
{
2547+
"id": self.poll.id,
2548+
"history_type": "+",
2549+
},
2550+
)
2551+
self.assertDatetimesEqual(record.history_date, datetime.now())
2552+
2553+
historical_poll = self.poll.history.all()[0]
2554+
2555+
# There should be no books associated with the current poll yet
2556+
self.assertEqual(historical_poll.books.count(), 0)
2557+
2558+
# Add a many-to-many child
2559+
self.poll.books.add(self.book)
2560+
2561+
# A new history row has been created by adding the M2M
2562+
self.assertEqual(self.poll.history.all().count(), 2)
2563+
2564+
# The new row has a place attached to it
2565+
m2m_record = self.poll.history.all()[0]
2566+
self.assertEqual(m2m_record.books.count(), 1)
2567+
2568+
# And the historical place is the correct one
2569+
historical_book = m2m_record.books.first()
2570+
self.assertEqual(historical_book.book, self.book)
2571+
2572+
25182573
@override_settings(**database_router_override_settings)
25192574
class MultiDBExplicitHistoryUserIDTest(TestCase):
25202575
databases = {"default", "other"}

0 commit comments

Comments
 (0)