Skip to content

Commit 693a591

Browse files
committed
Add m2m signals and specific methods
1 parent b8c8c93 commit 693a591

File tree

6 files changed

+242
-25
lines changed

6 files changed

+242
-25
lines changed

docs/signals.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ saving a historical record. Arguments passed to the signals include the followin
2222
using
2323
The database alias being used
2424

25+
For Many To Many signals you've got the following :
26+
27+
.. glossary::
28+
instance
29+
The source model instance being saved
30+
31+
history_instance
32+
The corresponding history record
33+
34+
rows (for pre_create)
35+
The elements to be bulk inserted into the m2m table
36+
37+
created_rows (for post_create)
38+
The created elements into the m2m table
39+
40+
field
41+
The recorded field object
42+
2543
To connect the signals to your callbacks, you can use the ``@receiver`` decorator:
2644

2745
.. code-block:: python
@@ -30,6 +48,8 @@ To connect the signals to your callbacks, you can use the ``@receiver`` decorato
3048
from simple_history.signals import (
3149
pre_create_historical_record,
3250
post_create_historical_record
51+
pre_create_historical_m2m_records,
52+
post_create_historical_m2m_records,
3353
)
3454
3555
@receiver(pre_create_historical_record)
@@ -39,3 +59,11 @@ To connect the signals to your callbacks, you can use the ``@receiver`` decorato
3959
@receiver(post_create_historical_record)
4060
def post_create_historical_record_callback(sender, **kwargs):
4161
print("Sent after saving historical record")
62+
63+
@receiver(pre_create_historical_m2m_records)
64+
def pre_create_historical_m2m_records_callback(sender, **kwargs):
65+
print("Sent before saving many to many field on historical record")
66+
67+
@receiver(post_create_historical_m2m_records)
68+
def post_create_historical_m2m_records_callback(sender, **kwargs):
69+
print("Sent after saving many to many field on historical record")

simple_history/models.py

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@
3232

3333
from . import exceptions
3434
from .manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoryDescriptor
35-
from .signals import post_create_historical_record, pre_create_historical_record
35+
from .signals import (
36+
post_create_historical_m2m_records,
37+
post_create_historical_record,
38+
pre_create_historical_m2m_records,
39+
pre_create_historical_record,
40+
)
3641
from .utils import get_change_reason_from_object
3742

3843
try:
@@ -95,6 +100,7 @@ def __init__(
95100
excluded_field_kwargs=None,
96101
m2m_fields=(),
97102
m2m_fields_model_field_name="_history_m2m_fields",
103+
m2m_bases=(models.Model,),
98104
):
99105
self.user_set_verbose_name = verbose_name
100106
self.user_set_verbose_name_plural = verbose_name_plural
@@ -134,6 +140,12 @@ def __init__(
134140
self.bases = (HistoricalChanges,) + tuple(bases)
135141
except TypeError:
136142
raise TypeError("The `bases` option must be a list or a tuple.")
143+
try:
144+
if isinstance(m2m_bases, str):
145+
raise TypeError
146+
self.m2m_bases = (HistoricalChanges,) + tuple(m2m_bases)
147+
except TypeError:
148+
raise TypeError("The `m2m_bases` option must be a list or a tuple.")
137149

138150
def contribute_to_class(self, cls, name):
139151
self.manager_name = name
@@ -239,35 +251,18 @@ def get_history_model_name(self, model):
239251
)
240252

241253
def create_history_m2m_model(self, model, through_model):
242-
attrs = {
243-
"__module__": model.__module__,
244-
"__str__": lambda self: "{} as of {}".format(
245-
self._meta.verbose_name, self.history.history_date
246-
),
247-
}
248-
249-
# Get the primary key to the history model this model will look up to
250-
attrs["m2m_history_id"] = self._get_history_id_field()
251-
attrs["history"] = models.ForeignKey(
252-
model,
253-
db_constraint=False,
254-
on_delete=models.DO_NOTHING,
255-
)
256-
attrs["instance_type"] = through_model
254+
attrs = {}
257255

258256
fields = self.copy_fields(through_model)
259257
attrs.update(fields)
258+
attrs.update(self.get_extra_fields_m2m(model, through_model, fields))
260259

261260
name = self.get_history_model_name(through_model)
262261
registered_models[through_model._meta.db_table] = through_model
263-
meta_fields = {"verbose_name": name}
264-
265-
if self.app:
266-
meta_fields["app_label"] = self.app
267262

268-
attrs.update(Meta=type(str("Meta"), (), meta_fields))
263+
attrs.update(Meta=type("Meta", (), self.get_meta_options_m2m(through_model)))
269264

270-
m2m_history_model = type(str(name), (models.Model,), attrs)
265+
m2m_history_model = type(str(name), self.m2m_bases, attrs)
271266

272267
return m2m_history_model
273268

@@ -458,6 +453,25 @@ def _get_history_related_field(self, model):
458453
else:
459454
return {}
460455

456+
def get_extra_fields_m2m(self, model, through_model, fields):
457+
"""Return dict of extra fields added to the m2m historical record model"""
458+
459+
extra_fields = {
460+
"__module__": model.__module__,
461+
"__str__": lambda self: "{} as of {}".format(
462+
self._meta.verbose_name, self.history.history_date
463+
),
464+
"history": models.ForeignKey(
465+
model,
466+
db_constraint=False,
467+
on_delete=models.DO_NOTHING,
468+
),
469+
"instance_type": through_model,
470+
"m2m_history_id": self._get_history_id_field(),
471+
}
472+
473+
return extra_fields
474+
461475
def get_extra_fields(self, model, fields):
462476
"""Return dict of extra fields added to the historical record model"""
463477

@@ -570,6 +584,20 @@ def _date_indexing(self):
570584
)
571585
return result
572586

587+
def get_meta_options_m2m(self, through_model):
588+
"""
589+
Returns a dictionary of fields that will be added to
590+
the Meta inner class of the m2m historical record model.
591+
"""
592+
name = self.get_history_model_name(through_model)
593+
594+
meta_fields = {"verbose_name": name}
595+
596+
if self.app:
597+
meta_fields["app_label"] = self.app
598+
599+
return meta_fields
600+
573601
def get_meta_options(self, model):
574602
"""
575603
Returns a dictionary of fields that will be added to
@@ -650,7 +678,21 @@ def create_historical_record_m2ms(self, history_instance, instance):
650678
)
651679
insert_rows.append(m2m_history_model(**insert_row))
652680

653-
m2m_history_model.objects.bulk_create(insert_rows)
681+
pre_create_historical_m2m_records.send(
682+
sender=m2m_history_model,
683+
rows=insert_rows,
684+
history_instance=history_instance,
685+
instance=instance,
686+
field=field,
687+
)
688+
created_rows = m2m_history_model.objects.bulk_create(insert_rows)
689+
post_create_historical_m2m_records.send(
690+
sender=m2m_history_model,
691+
created_rows=created_rows,
692+
history_instance=history_instance,
693+
instance=instance,
694+
field=field,
695+
)
654696

655697
def create_historical_record(self, instance, history_type, using=None):
656698
using = using if self.use_base_model_db else None

simple_history/signals.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,11 @@
77
# Arguments: "instance", "history_instance", "history_date",
88
# "history_user", "history_change_reason", "using"
99
post_create_historical_record = django.dispatch.Signal()
10+
11+
# Arguments: "sender", "rows", "history_instance", "instance",
12+
# "field"
13+
pre_create_historical_m2m_records = django.dispatch.Signal()
14+
15+
# Arguments: "sender", "created_rows", "history_instance",
16+
# "instance", "field"
17+
post_create_historical_m2m_records = django.dispatch.Signal()

simple_history/tests/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,27 @@ class PollWithManyToMany(models.Model):
117117
history = HistoricalRecords(m2m_fields=[places])
118118

119119

120+
class HistoricalRecordsWithExtraFieldM2M(HistoricalRecords):
121+
def get_extra_fields_m2m(self, model, through_model, fields):
122+
extra_fields = super().get_extra_fields_m2m(model, through_model, fields)
123+
124+
def get_class_name(self):
125+
return self.__class__.__name__
126+
127+
extra_fields["get_class_name"] = get_class_name
128+
return extra_fields
129+
130+
131+
class PollWithManyToManyWithIPAddress(models.Model):
132+
question = models.CharField(max_length=200)
133+
pub_date = models.DateTimeField("date published")
134+
places = models.ManyToManyField("Place")
135+
136+
history = HistoricalRecordsWithExtraFieldM2M(
137+
m2m_fields=[places], m2m_bases=[IPAddressHistoricalModel]
138+
)
139+
140+
120141
class PollWithSeveralManyToMany(models.Model):
121142
question = models.CharField(max_length=200)
122143
pub_date = models.DateTimeField("date published")

simple_history/tests/tests/test_models.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
is_historic,
2525
to_historic,
2626
)
27-
from simple_history.signals import pre_create_historical_record
27+
from simple_history.signals import (
28+
pre_create_historical_m2m_records,
29+
pre_create_historical_record,
30+
)
2831
from simple_history.tests.custom_user.models import CustomUser
2932
from simple_history.tests.tests.utils import (
3033
database_router_override_settings,
@@ -95,6 +98,7 @@
9598
PollWithExcludeFields,
9699
PollWithHistoricalIPAddress,
97100
PollWithManyToMany,
101+
PollWithManyToManyWithIPAddress,
98102
PollWithNonEditableField,
99103
PollWithSeveralManyToMany,
100104
Province,
@@ -1487,6 +1491,11 @@ def add_static_history_ip_address(sender, **kwargs):
14871491
history_instance.ip_address = "192.168.0.1"
14881492

14891493

1494+
def add_static_history_ip_address_on_m2m(sender, rows, **kwargs):
1495+
for row in rows:
1496+
row.ip_address = "192.168.0.1"
1497+
1498+
14901499
class ExtraFieldsStaticIPAddressTestCase(TestCase):
14911500
def setUp(self):
14921501
pre_create_historical_record.connect(
@@ -1790,6 +1799,55 @@ def test_separation(self):
17901799
self.assertEqual(add.places.all().count(), 0)
17911800

17921801

1802+
class ManyToManyWithSignalsTest(TestCase):
1803+
def setUp(self):
1804+
self.model = PollWithManyToManyWithIPAddress
1805+
# self.historical_through_model = self.model.history.
1806+
self.places = (
1807+
Place.objects.create(name="London"),
1808+
Place.objects.create(name="Paris"),
1809+
)
1810+
self.poll = self.model.objects.create(question="what's up?", pub_date=today)
1811+
pre_create_historical_m2m_records.connect(
1812+
add_static_history_ip_address_on_m2m,
1813+
dispatch_uid="add_static_history_ip_address_on_m2m",
1814+
)
1815+
1816+
def tearDown(self):
1817+
pre_create_historical_m2m_records.disconnect(
1818+
add_static_history_ip_address_on_m2m,
1819+
dispatch_uid="add_static_history_ip_address_on_m2m",
1820+
)
1821+
1822+
def test_ip_address_added(self):
1823+
self.poll.places.add(*self.places)
1824+
1825+
places = self.poll.history.first().places
1826+
self.assertEqual(2, places.count())
1827+
for place in places.all():
1828+
self.assertEqual("192.168.0.1", place.ip_address)
1829+
1830+
def test_extra_field(self):
1831+
self.poll.places.add(*self.places)
1832+
m2m_record = self.poll.history.first().places.first()
1833+
self.assertEqual(
1834+
m2m_record.get_class_name(),
1835+
"HistoricalPollWithManyToManyWithIPAddress_places",
1836+
)
1837+
1838+
def test_diff(self):
1839+
self.poll.places.clear()
1840+
self.poll.places.add(*self.places)
1841+
1842+
new = self.poll.history.first()
1843+
old = new.prev_record
1844+
1845+
delta = new.diff_against(old)
1846+
1847+
self.assertEqual("places", delta.changes[0].field)
1848+
self.assertEqual(2, len(delta.changes[0].new))
1849+
1850+
17931851
class ManyToManyTest(TestCase):
17941852
def setUp(self):
17951853
self.model = PollWithManyToMany

0 commit comments

Comments
 (0)