Skip to content

Commit 0700691

Browse files
bmedxthijskramer
authored andcommitted
Add m2m tracking support to django-simple-history
Working prototype with tests - Custom through models may not work correctly - Currently storing IDs for FKs, probably needs replacing Now using model instances instead of IDs when possible flake8 fixes Delete dead code, comments, and change to bulk create Clean up, add m2m support to diff_against Add basic tests for several m2m fields on one model Formatting fixes from Black Housekeeping, fixing Python 2.7, removing Python 3.4 - Py3.4 removed as coverage no longer supports it, could be pinned instead, but I went with this. remove python2 thing
1 parent 3070deb commit 0700691

File tree

5 files changed

+469
-1
lines changed

5 files changed

+469
-1
lines changed

AUTHORS.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ Authors
2020
- Benjamin Mampaey (`bmampaey <https://github.com/bmampaey>`_)
2121
- `bradford281 <https://github.com/bradford281>`_
2222
- Brian Armstrong (`barm <https://github.com/barm>`_)
23-
- Buddy Lindsey, Jr.
2423
- Brian Dixon
24+
- Brian Mesick (`bmedx <https://github.com/bmedx>`_)
25+
- Buddy Lindsey, Jr.
2526
- Carlos San Emeterio (`Carlos-San-Emeterio <https://github.com/Carlos-San-Emeterio>`_)
2627
- Christopher Broderick (`uhurusurfa <https://github.com/uhurusurfa>`_)
2728
- Christopher Johns (`tyrantwave <https://github.com/tyrantwave>`_)

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

1112
3.1.1 (2022-04-23)
1213
------------------

simple_history/models.py

Lines changed: 110 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,15 @@ 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+
for field in old_history._history_m2m_fields:
905+
old_rows = list(getattr(old_history, field.name).values_list())
906+
new_rows = list(getattr(self, field.name).values_list())
907+
908+
if old_rows != new_rows:
909+
change = ModelChange(field.name, old_rows, new_rows)
910+
changes.append(change)
911+
changed_fields.append(field.name)
912+
803913
return ModelDelta(changes, changed_fields, old_history, self)
804914

805915

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)