22import importlib
33import uuid
44import warnings
5+ from functools import partial
56
67from django .apps import apps
78from django .conf import settings
1819 create_reverse_many_to_one_manager ,
1920)
2021from django .db .models .query import QuerySet
22+ from django .db .models .signals import m2m_changed
2123from django .forms .models import model_to_dict
2224from django .urls import reverse
2325from django .utils import timezone
@@ -65,6 +67,7 @@ def _history_user_setter(historical_instance, user):
6567
6668class 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
0 commit comments