2
2
import importlib
3
3
import uuid
4
4
import warnings
5
+ from functools import partial
5
6
6
7
from django .apps import apps
7
8
from django .conf import settings
18
19
create_reverse_many_to_one_manager ,
19
20
)
20
21
from django .db .models .query import QuerySet
22
+ from django .db .models .signals import m2m_changed
21
23
from django .forms .models import model_to_dict
22
24
from django .urls import reverse
23
25
from django .utils import timezone
@@ -65,6 +67,7 @@ def _history_user_setter(historical_instance, user):
65
67
66
68
class HistoricalRecords :
67
69
thread = context = LocalContext () # retain thread for backwards compatibility
70
+ m2m_models = {}
68
71
69
72
def __init__ (
70
73
self ,
@@ -90,6 +93,7 @@ def __init__(
90
93
user_db_constraint = True ,
91
94
no_db_index = list (),
92
95
excluded_field_kwargs = None ,
96
+ m2m_fields = (),
93
97
):
94
98
self .user_set_verbose_name = verbose_name
95
99
self .user_set_verbose_name_plural = verbose_name_plural
@@ -109,6 +113,7 @@ def __init__(
109
113
self .user_setter = history_user_setter
110
114
self .related_name = related_name
111
115
self .use_base_model_db = use_base_model_db
116
+ self .m2m_fields = m2m_fields
112
117
113
118
if isinstance (no_db_index , str ):
114
119
no_db_index = [no_db_index ]
@@ -172,6 +177,7 @@ def finalize(self, sender, **kwargs):
172
177
)
173
178
)
174
179
history_model = self .create_history_model (sender , inherited )
180
+
175
181
if inherited :
176
182
# Make sure history model is in same module as concrete model
177
183
module = importlib .import_module (history_model .__module__ )
@@ -183,11 +189,29 @@ def finalize(self, sender, **kwargs):
183
189
# so the signal handlers can't use weak references.
184
190
models .signals .post_save .connect (self .post_save , sender = sender , weak = False )
185
191
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
+ )
186
198
187
199
descriptor = HistoryDescriptor (history_model )
188
200
setattr (sender , self .manager_name , descriptor )
189
201
sender ._meta .simple_history_manager_attribute = self .manager_name
190
202
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
+
191
215
def get_history_model_name (self , model ):
192
216
if not self .custom_model_name :
193
217
return f"Historical{ model ._meta .object_name } "
@@ -210,13 +234,58 @@ def get_history_model_name(self, model):
210
234
)
211
235
)
212
236
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
+
213
281
def create_history_model (self , model , inherited ):
214
282
"""
215
283
Creates a historical model to associate with the model provided.
216
284
"""
217
285
attrs = {
218
286
"__module__" : self .module ,
219
287
"_history_excluded_fields" : self .excluded_fields ,
288
+ "_history_m2m_fields" : self .m2m_fields ,
220
289
}
221
290
222
291
app_module = "%s.models" % model ._meta .app_label
@@ -559,6 +628,37 @@ def get_change_reason_for_object(self, instance, history_type, using):
559
628
"""
560
629
return get_change_reason_from_object (instance )
561
630
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
+
562
662
def create_historical_record (self , instance , history_type , using = None ):
563
663
using = using if self .use_base_model_db else None
564
664
history_date = getattr (instance , "_history_date" , timezone .now ())
@@ -595,6 +695,7 @@ def create_historical_record(self, instance, history_type, using=None):
595
695
)
596
696
597
697
history_instance .save (using = using )
698
+ self .create_historical_record_m2ms (history_instance , instance )
598
699
599
700
post_create_historical_record .send (
600
701
sender = manager .model ,
@@ -800,6 +901,15 @@ def diff_against(self, old_history, excluded_fields=None, included_fields=None):
800
901
changes .append (ModelChange (field , old_value , current_value ))
801
902
changed_fields .append (field )
802
903
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
+
803
913
return ModelDelta (changes , changed_fields , old_history , self )
804
914
805
915
0 commit comments