Skip to content

Commit 52dafb6

Browse files
authored
Merge pull request #1042 from legau/m2m-support
M2M support for inheritance and signals
2 parents 7042a64 + a8c08b7 commit 52dafb6

File tree

8 files changed

+391
-53
lines changed

8 files changed

+391
-53
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

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: 98 additions & 50 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:
@@ -94,6 +99,8 @@ def __init__(
9499
no_db_index=list(),
95100
excluded_field_kwargs=None,
96101
m2m_fields=(),
102+
m2m_fields_model_field_name="_history_m2m_fields",
103+
m2m_bases=(models.Model,),
97104
):
98105
self.user_set_verbose_name = verbose_name
99106
self.user_set_verbose_name_plural = verbose_name_plural
@@ -114,6 +121,7 @@ def __init__(
114121
self.related_name = related_name
115122
self.use_base_model_db = use_base_model_db
116123
self.m2m_fields = m2m_fields
124+
self.m2m_fields_model_field_name = m2m_fields_model_field_name
117125

118126
if isinstance(no_db_index, str):
119127
no_db_index = [no_db_index]
@@ -132,6 +140,12 @@ def __init__(
132140
self.bases = (HistoricalChanges,) + tuple(bases)
133141
except TypeError:
134142
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.")
135149

136150
def contribute_to_class(self, cls, name):
137151
self.manager_name = name
@@ -189,7 +203,10 @@ def finalize(self, sender, **kwargs):
189203
# so the signal handlers can't use weak references.
190204
models.signals.post_save.connect(self.post_save, sender=sender, weak=False)
191205
models.signals.post_delete.connect(self.post_delete, sender=sender, weak=False)
192-
for field in self.m2m_fields:
206+
207+
m2m_fields = self.get_m2m_fields_from_model(sender)
208+
209+
for field in m2m_fields:
193210
m2m_changed.connect(
194211
partial(self.m2m_changed, attr=field.name),
195212
sender=field.remote_field.through,
@@ -200,13 +217,12 @@ def finalize(self, sender, **kwargs):
200217
setattr(sender, self.manager_name, descriptor)
201218
sender._meta.simple_history_manager_attribute = self.manager_name
202219

203-
for field in self.m2m_fields:
220+
for field in m2m_fields:
204221
m2m_model = self.create_history_m2m_model(
205222
history_model, field.remote_field.through
206223
)
207224
self.m2m_models[field] = m2m_model
208225

209-
module = importlib.import_module(self.module)
210226
setattr(module, m2m_model.__name__, m2m_model)
211227

212228
m2m_descriptor = HistoryDescriptor(m2m_model)
@@ -235,46 +251,18 @@ def get_history_model_name(self, model):
235251
)
236252

237253
def create_history_m2m_model(self, model, through_model):
238-
attrs = {
239-
"__module__": self.module,
240-
"__str__": lambda self: "{} as of {}".format(
241-
self._meta.verbose_name, self.history.history_date
242-
),
243-
}
244-
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-
256-
# Get the primary key to the history model this model will look up to
257-
attrs["m2m_history_id"] = self._get_history_id_field()
258-
attrs["history"] = models.ForeignKey(
259-
model,
260-
db_constraint=False,
261-
on_delete=models.DO_NOTHING,
262-
)
263-
attrs["instance_type"] = through_model
254+
attrs = {}
264255

265256
fields = self.copy_fields(through_model)
266257
attrs.update(fields)
258+
attrs.update(self.get_extra_fields_m2m(model, through_model, fields))
267259

268260
name = self.get_history_model_name(through_model)
269261
registered_models[through_model._meta.db_table] = through_model
270-
meta_fields = {"verbose_name": name}
271262

272-
if self.app:
273-
meta_fields["app_label"] = self.app
274-
275-
attrs.update(Meta=type(str("Meta"), (), meta_fields))
263+
attrs.update(Meta=type("Meta", (), self.get_meta_options_m2m(through_model)))
276264

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

279267
return m2m_history_model
280268

@@ -285,7 +273,7 @@ def create_history_model(self, model, inherited):
285273
attrs = {
286274
"__module__": self.module,
287275
"_history_excluded_fields": self.excluded_fields,
288-
"_history_m2m_fields": self.m2m_fields,
276+
"_history_m2m_fields": self.get_m2m_fields_from_model(model),
289277
}
290278

291279
app_module = "%s.models" % model._meta.app_label
@@ -412,7 +400,7 @@ def _get_history_change_reason_field(self):
412400

413401
def _get_history_id_field(self):
414402
if self.history_id_field:
415-
history_id_field = self.history_id_field
403+
history_id_field = self.history_id_field.clone()
416404
history_id_field.primary_key = True
417405
history_id_field.editable = False
418406
elif getattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID", False):
@@ -465,6 +453,25 @@ def _get_history_related_field(self, model):
465453
else:
466454
return {}
467455

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+
468475
def get_extra_fields(self, model, fields):
469476
"""Return dict of extra fields added to the historical record model"""
470477

@@ -577,6 +584,20 @@ def _date_indexing(self):
577584
)
578585
return result
579586

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+
580601
def get_meta_options(self, model):
581602
"""
582603
Returns a dictionary of fields that will be added to
@@ -637,7 +658,7 @@ def m2m_changed(self, instance, action, attr, pk_set, reverse, **_):
637658
self.create_historical_record(instance, "~")
638659

639660
def create_historical_record_m2ms(self, history_instance, instance):
640-
for field in self.m2m_fields:
661+
for field in history_instance._history_m2m_fields:
641662
m2m_history_model = self.m2m_models[field]
642663
original_instance = history_instance.instance
643664
through_model = getattr(original_instance, field.name).through
@@ -657,7 +678,21 @@ def create_historical_record_m2ms(self, history_instance, instance):
657678
)
658679
insert_rows.append(m2m_history_model(**insert_row))
659680

660-
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+
)
661696

662697
def create_historical_record(self, instance, history_type, using=None):
663698
using = using if self.use_base_model_db else None
@@ -721,6 +756,14 @@ def get_history_user(self, instance):
721756

722757
return self.get_user(instance=instance, request=request)
723758

759+
def get_m2m_fields_from_model(self, model):
760+
m2m_fields = set(self.m2m_fields)
761+
try:
762+
m2m_fields.update(getattr(model, self.m2m_fields_model_field_name))
763+
except AttributeError:
764+
pass
765+
return [getattr(model, field.name).field for field in m2m_fields]
766+
724767

725768
def transform_field(field):
726769
"""Customize field appropriately for use in historical model"""
@@ -880,12 +923,20 @@ def diff_against(self, old_history, excluded_fields=None, included_fields=None):
880923
if excluded_fields is None:
881924
excluded_fields = set()
882925

926+
included_m2m_fields = {field.name for field in old_history._history_m2m_fields}
883927
if included_fields is None:
884928
included_fields = {
885929
f.name for f in old_history.instance_type._meta.fields if f.editable
886930
}
931+
else:
932+
included_m2m_fields = included_m2m_fields.intersection(included_fields)
887933

888-
fields = set(included_fields).difference(excluded_fields)
934+
fields = (
935+
set(included_fields)
936+
.difference(included_m2m_fields)
937+
.difference(excluded_fields)
938+
)
939+
m2m_fields = set(included_m2m_fields).difference(excluded_fields)
889940

890941
changes = []
891942
changed_fields = []
@@ -902,11 +953,10 @@ def diff_against(self, old_history, excluded_fields=None, included_fields=None):
902953
changed_fields.append(field)
903954

904955
# Separately compare m2m fields:
905-
for field in old_history._history_m2m_fields:
956+
for field in m2m_fields:
906957
# First retrieve a single item to get the field names from:
907958
reference_history_m2m_item = (
908-
getattr(old_history, field.name).first()
909-
or getattr(self, field.name).first()
959+
getattr(old_history, field).first() or getattr(self, field).first()
910960
)
911961
history_field_names = []
912962
if reference_history_m2m_item:
@@ -920,15 +970,13 @@ def diff_against(self, old_history, excluded_fields=None, included_fields=None):
920970
if f.editable and f.name not in ["id", "m2m_history_id", "history"]
921971
]
922972

923-
old_rows = list(
924-
getattr(old_history, field.name).values(*history_field_names)
925-
)
926-
new_rows = list(getattr(self, field.name).values(*history_field_names))
973+
old_rows = list(getattr(old_history, field).values(*history_field_names))
974+
new_rows = list(getattr(self, field).values(*history_field_names))
927975

928976
if old_rows != new_rows:
929-
change = ModelChange(field.name, old_rows, new_rows)
977+
change = ModelChange(field, old_rows, new_rows)
930978
changes.append(change)
931-
changed_fields.append(field.name)
979+
changed_fields.append(field)
932980

933981
return ModelDelta(changes, changed_fields, old_history, self)
934982

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()

0 commit comments

Comments
 (0)