Skip to content

Commit 7042a64

Browse files
authored
Merge pull request #932 from thijskramer/m2m-support
Enable diffing m2m fields
2 parents 529dbe2 + 603c7e3 commit 7042a64

File tree

6 files changed

+533
-1
lines changed

6 files changed

+533
-1
lines changed

AUTHORS.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ Authors
2121
- Bheesham Persaud (`bheesham <https://github.com/bheesham>`_)
2222
- `bradford281 <https://github.com/bradford281>`_
2323
- Brian Armstrong (`barm <https://github.com/barm>`_)
24-
- Buddy Lindsey, Jr.
2524
- Brian Dixon
25+
- Brian Mesick (`bmedx <https://github.com/bmedx>`_)
26+
- Buddy Lindsey, Jr.
2627
- Carlos San Emeterio (`Carlos-San-Emeterio <https://github.com/Carlos-San-Emeterio>`_)
2728
- Christopher Broderick (`uhurusurfa <https://github.com/uhurusurfa>`_)
2829
- Christopher Johns (`tyrantwave <https://github.com/tyrantwave>`_)
@@ -114,6 +115,7 @@ Authors
114115
- Stefan Borer (`sbor23 <https://github.com/sbor23>`_)
115116
- Steven Buss (`sbuss <https://github.com/sbuss>`_)
116117
- Steven Klass
118+
- Thijs Kramer (`thijskramer <https://github.com/thijskramer>`_)
117119
- Tim Schilling (`tim-schilling <https://github.com/tim-schilling>`_)
118120
- Todd Wolfson (`twolfson <https://github.com/twolfson>`_)
119121
- Tommy Beadle (`tbeadle <https://github.com/tbeadle>`_)

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Unreleased
77
- Fixed typos in the docs
88
- Removed n+1 query from ``bulk_create_with_history`` utility (gh-975)
99
- Started using ``exists`` query instead of ``count`` in ``populate_history`` command (gh-982)
10+
- Add basic support for many-to-many fields (gh-399)
1011
- Added support for Django 4.1 (gh-1021)
1112

1213
3.1.1 (2022-04-23)

docs/historical_model.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,42 @@ And you don't want to create database index for ``question``, it is necessary to
447447
448448
By default, django-simple-history keeps all indices. and even forces them on unique fields and relations.
449449
WARNING: This will drop performance on historical lookups
450+
451+
Tracking many to many relationships
452+
-----------------------------------
453+
By default, many to many fields are ignored when tracking changes.
454+
If you want to track many to many relationships, you need to define them explicitly:
455+
456+
.. code-block:: python
457+
458+
class Category(models.Model):
459+
pass
460+
461+
class Poll(models.Model):
462+
question = models.CharField(max_length=200)
463+
categories = models.ManyToManyField(Category)
464+
history = HistoricalRecords(many_to_many=[categories])
465+
466+
This will create a historical intermediate model that tracks each relational change
467+
between `Poll` and `Category`.
468+
469+
You will see the many to many changes when diffing between two historical records:
470+
471+
.. code-block:: python
472+
473+
informal = Category(name="informal questions")
474+
official = Category(name="official questions")
475+
p = Poll.objects.create(question="what's up?")
476+
p.save()
477+
p.categories.add(informal, official)
478+
p.categories.remove(informal)
479+
480+
last_record = p.history.latest()
481+
previous_record = last_record.prev_record()
482+
delta = last_record.diff_against(previous_record)
483+
484+
for change in delta.changes:
485+
print("{} changed from {} to {}")
486+
487+
# Output:
488+
# categories changed from [{'poll': 1, 'category': 1}, { 'poll': 1, 'category': 2}] to [{'poll': 1, 'category': 2}]

simple_history/models.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import importlib
33
import uuid
44
import warnings
5+
from functools import partial
56

67
from django.apps import apps
78
from django.conf import settings
@@ -18,6 +19,7 @@
1819
create_reverse_many_to_one_manager,
1920
)
2021
from django.db.models.query import QuerySet
22+
from django.db.models.signals import m2m_changed
2123
from django.forms.models import model_to_dict
2224
from django.urls import reverse
2325
from django.utils import timezone
@@ -65,6 +67,7 @@ def _history_user_setter(historical_instance, user):
6567

6668
class HistoricalRecords:
6769
thread = context = LocalContext() # retain thread for backwards compatibility
70+
m2m_models = {}
6871

6972
def __init__(
7073
self,
@@ -90,6 +93,7 @@ def __init__(
9093
user_db_constraint=True,
9194
no_db_index=list(),
9295
excluded_field_kwargs=None,
96+
m2m_fields=(),
9397
):
9498
self.user_set_verbose_name = verbose_name
9599
self.user_set_verbose_name_plural = verbose_name_plural
@@ -109,6 +113,7 @@ def __init__(
109113
self.user_setter = history_user_setter
110114
self.related_name = related_name
111115
self.use_base_model_db = use_base_model_db
116+
self.m2m_fields = m2m_fields
112117

113118
if isinstance(no_db_index, str):
114119
no_db_index = [no_db_index]
@@ -172,6 +177,7 @@ def finalize(self, sender, **kwargs):
172177
)
173178
)
174179
history_model = self.create_history_model(sender, inherited)
180+
175181
if inherited:
176182
# Make sure history model is in same module as concrete model
177183
module = importlib.import_module(history_model.__module__)
@@ -183,11 +189,29 @@ def finalize(self, sender, **kwargs):
183189
# so the signal handlers can't use weak references.
184190
models.signals.post_save.connect(self.post_save, sender=sender, weak=False)
185191
models.signals.post_delete.connect(self.post_delete, sender=sender, weak=False)
192+
for field in self.m2m_fields:
193+
m2m_changed.connect(
194+
partial(self.m2m_changed, attr=field.name),
195+
sender=field.remote_field.through,
196+
weak=False,
197+
)
186198

187199
descriptor = HistoryDescriptor(history_model)
188200
setattr(sender, self.manager_name, descriptor)
189201
sender._meta.simple_history_manager_attribute = self.manager_name
190202

203+
for field in self.m2m_fields:
204+
m2m_model = self.create_history_m2m_model(
205+
history_model, field.remote_field.through
206+
)
207+
self.m2m_models[field] = m2m_model
208+
209+
module = importlib.import_module(self.module)
210+
setattr(module, m2m_model.__name__, m2m_model)
211+
212+
m2m_descriptor = HistoryDescriptor(m2m_model)
213+
setattr(history_model, field.name, m2m_descriptor)
214+
191215
def get_history_model_name(self, model):
192216
if not self.custom_model_name:
193217
return f"Historical{model._meta.object_name}"
@@ -210,13 +234,58 @@ def get_history_model_name(self, model):
210234
)
211235
)
212236

237+
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
264+
265+
fields = self.copy_fields(through_model)
266+
attrs.update(fields)
267+
268+
name = self.get_history_model_name(through_model)
269+
registered_models[through_model._meta.db_table] = through_model
270+
meta_fields = {"verbose_name": name}
271+
272+
if self.app:
273+
meta_fields["app_label"] = self.app
274+
275+
attrs.update(Meta=type(str("Meta"), (), meta_fields))
276+
277+
m2m_history_model = type(str(name), (models.Model,), attrs)
278+
279+
return m2m_history_model
280+
213281
def create_history_model(self, model, inherited):
214282
"""
215283
Creates a historical model to associate with the model provided.
216284
"""
217285
attrs = {
218286
"__module__": self.module,
219287
"_history_excluded_fields": self.excluded_fields,
288+
"_history_m2m_fields": self.m2m_fields,
220289
}
221290

222291
app_module = "%s.models" % model._meta.app_label
@@ -559,6 +628,37 @@ def get_change_reason_for_object(self, instance, history_type, using):
559628
"""
560629
return get_change_reason_from_object(instance)
561630

631+
def m2m_changed(self, instance, action, attr, pk_set, reverse, **_):
632+
if hasattr(instance, "skip_history_when_saving"):
633+
return
634+
635+
if action in ("post_add", "post_remove", "post_clear"):
636+
# It should be safe to ~ this since the row must exist to modify m2m on it
637+
self.create_historical_record(instance, "~")
638+
639+
def create_historical_record_m2ms(self, history_instance, instance):
640+
for field in self.m2m_fields:
641+
m2m_history_model = self.m2m_models[field]
642+
original_instance = history_instance.instance
643+
through_model = getattr(original_instance, field.name).through
644+
645+
insert_rows = []
646+
647+
through_field_name = type(original_instance).__name__.lower()
648+
649+
rows = through_model.objects.filter(**{through_field_name: instance})
650+
651+
for row in rows:
652+
insert_row = {"history": history_instance}
653+
654+
for through_model_field in through_model._meta.fields:
655+
insert_row[through_model_field.name] = getattr(
656+
row, through_model_field.name
657+
)
658+
insert_rows.append(m2m_history_model(**insert_row))
659+
660+
m2m_history_model.objects.bulk_create(insert_rows)
661+
562662
def create_historical_record(self, instance, history_type, using=None):
563663
using = using if self.use_base_model_db else None
564664
history_date = getattr(instance, "_history_date", timezone.now())
@@ -595,6 +695,7 @@ def create_historical_record(self, instance, history_type, using=None):
595695
)
596696

597697
history_instance.save(using=using)
698+
self.create_historical_record_m2ms(history_instance, instance)
598699

599700
post_create_historical_record.send(
600701
sender=manager.model,
@@ -800,6 +901,35 @@ def diff_against(self, old_history, excluded_fields=None, included_fields=None):
800901
changes.append(ModelChange(field, old_value, current_value))
801902
changed_fields.append(field)
802903

904+
# Separately compare m2m fields:
905+
for field in old_history._history_m2m_fields:
906+
# First retrieve a single item to get the field names from:
907+
reference_history_m2m_item = (
908+
getattr(old_history, field.name).first()
909+
or getattr(self, field.name).first()
910+
)
911+
history_field_names = []
912+
if reference_history_m2m_item:
913+
# Create a list of field names to compare against.
914+
# The list is generated without the primary key of the intermediate
915+
# table, the foreign key to the history record, and the actual 'history'
916+
# field, to avoid false positives while diffing.
917+
history_field_names = [
918+
f.name
919+
for f in reference_history_m2m_item._meta.fields
920+
if f.editable and f.name not in ["id", "m2m_history_id", "history"]
921+
]
922+
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))
927+
928+
if old_rows != new_rows:
929+
change = ModelChange(field.name, old_rows, new_rows)
930+
changes.append(change)
931+
changed_fields.append(field.name)
932+
803933
return ModelDelta(changes, changed_fields, old_history, self)
804934

805935

simple_history/tests/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,24 @@ def get_absolute_url(self):
109109
return reverse("poll-detail", kwargs={"pk": self.pk})
110110

111111

112+
class PollWithManyToMany(models.Model):
113+
question = models.CharField(max_length=200)
114+
pub_date = models.DateTimeField("date published")
115+
places = models.ManyToManyField("Place")
116+
117+
history = HistoricalRecords(m2m_fields=[places])
118+
119+
120+
class PollWithSeveralManyToMany(models.Model):
121+
question = models.CharField(max_length=200)
122+
pub_date = models.DateTimeField("date published")
123+
places = models.ManyToManyField("Place", related_name="places_poll")
124+
restaurants = models.ManyToManyField("Restaurant", related_name="restaurants_poll")
125+
books = models.ManyToManyField("Book", related_name="books_poll")
126+
127+
history = HistoricalRecords(m2m_fields=[places, restaurants, books])
128+
129+
112130
class CustomAttrNameForeignKey(models.ForeignKey):
113131
def __init__(self, *args, **kwargs):
114132
self.attr_name = kwargs.pop("attr_name", None)

0 commit comments

Comments
 (0)