44from collections import defaultdict
55
66from cache_memoize import cache_memoize
7+ from django .core .cache import cache
78from django .core .exceptions import ObjectDoesNotExist , PermissionDenied , ValidationError
89from django .db import models , transaction
910from django .utils .translation import gettext_lazy as _
@@ -110,6 +111,7 @@ class AbstractConfig(ChecksumCacheMixin, BaseConfig):
110111 )
111112
112113 _CHECKSUM_CACHE_TIMEOUT = ChecksumCacheMixin ._CHECKSUM_CACHE_TIMEOUT
114+ _CONFIG_MODIFIED_TIMEOUT = 3
113115 _config_context_functions = list ()
114116 _old_backend = None
115117
@@ -124,9 +126,11 @@ def __init__(self, *args, **kwargs):
124126 self ._just_created = False
125127 self ._initial_status = self .status
126128 self ._send_config_modified_after_save = False
129+ self ._config_modified_action = "config_changed"
127130 self ._send_config_deactivated = False
128131 self ._send_config_deactivating = False
129132 self ._send_config_status_changed = False
133+ self ._is_enforcing_required_templates = False
130134
131135 def __str__ (self ):
132136 if self ._has_device ():
@@ -301,17 +305,26 @@ def templates_changed(cls, action, instance, **kwargs):
301305 # execute only after a config has been saved or deleted
302306 if action not in ["post_add" , "post_remove" ] or instance ._state .adding :
303307 return
308+ if instance ._is_enforcing_required_templates :
309+ # The required templates are enforced on "post_clear" action and
310+ # they are added back using Config.templates.add(). This sends a
311+ # m2m_changed signal with the "post_add" action.
312+ # At this stage, all templates have not yet been re-added,
313+ # so the checksum cannot be accurately evaluated.
314+ # Defer checksum validation until a subsequent post_add or
315+ # post_remove signal is received.
316+ instance ._is_enforcing_required_templates = False
317+ return
304318 # use atomic to ensure any code bound to
305319 # be executed via transaction.on_commit
306320 # is executed after the whole block
307321 with transaction .atomic ():
308322 # do not send config modified signal if
309323 # config instance has just been created
310324 if not instance ._just_created :
311- # sends only config modified signal
312- instance ._send_config_modified_signal (action = "m2m_templates_changed" )
325+ instance ._config_modified_action = "m2m_templates_changed"
313326 instance .update_status_if_checksum_changed (
314- send_config_modified_signal = False
327+ send_config_modified_signal = not instance . _just_created
315328 )
316329
317330 @classmethod
@@ -464,6 +477,7 @@ def enforce_required_templates(
464477 )
465478 )
466479 if required_templates .exists ():
480+ instance ._is_enforcing_required_templates = True
467481 instance .templates .add (
468482 * required_templates .order_by ("name" ).values_list ("pk" , flat = True )
469483 )
@@ -587,7 +601,7 @@ def save(self, *args, **kwargs):
587601 self ._old_backend = None
588602 # emit signals if config is modified and/or if status is changing
589603 if not created and self ._send_config_modified_after_save :
590- self ._send_config_modified_signal (action = "config_changed" )
604+ self ._send_config_modified_signal ()
591605 self ._send_config_modified_after_save = False
592606 if self ._send_config_status_changed :
593607 self ._send_config_status_changed_signal ()
@@ -648,6 +662,14 @@ def update_status_if_checksum_changed(
648662 self ._update_checksum_db (new_checksum = self .checksum_db )
649663 if send_config_modified_signal :
650664 self ._send_config_modified_after_save = True
665+ if save :
666+ # When this method is triggered by changes to Config.templates,
667+ # those changes are applied through the related manager rather
668+ # than via Config.save(). As a result, the model's save()
669+ # method (and thus the automatic "config modified" signal)
670+ # is never invoked. To ensure the signal is still emitted,
671+ # we send it explicitly here.
672+ self ._send_config_modified_signal ()
651673 self .invalidate_checksum_cache ()
652674 return checksum_changed
653675
@@ -678,11 +700,38 @@ def _update_checksum_db(self, new_checksum=None):
678700 new_checksum = new_checksum or self .checksum
679701 self ._meta .model .objects .filter (pk = self .pk ).update (checksum_db = new_checksum )
680702
681- def _send_config_modified_signal (self , action ):
703+ @property
704+ def _config_modified_timeout_cache_key (self ):
705+ return f"config_modified_timeout_{ self .pk } "
706+
707+ def _set_config_modified_timeout_cache (self ):
708+ cache .set (
709+ self ._config_modified_timeout_cache_key ,
710+ True ,
711+ timeout = self ._CONFIG_MODIFIED_TIMEOUT ,
712+ )
713+
714+ def _delete_config_modified_timeout_cache (self ):
715+ cache .delete (self ._config_modified_timeout_cache_key )
716+
717+ def _send_config_modified_signal (self , action = None ):
682718 """
683719 Emits ``config_modified`` signal.
684- Called also by Template when templates of a device are modified
720+
721+ A short-lived cache key prevents emitting duplicate signals inside the
722+ same change window; if that key exists the method returns early without
723+ emitting the signal again.
724+
725+ Side effects
726+ ------------
727+ - Emits the ``config_modified`` Django signal with contextual data.
728+ - Resets ``_config_modified_action`` back to ``"config_changed"`` so
729+ subsequent calls without an explicit action revert to the default.
730+ - Sets the debouncing cache key to avoid duplicate emissions.
685731 """
732+ if cache .get (self ._config_modified_timeout_cache_key ):
733+ return
734+ action = action or self ._config_modified_action
686735 assert action in [
687736 "config_changed" ,
688737 "related_template_changed" ,
@@ -697,6 +746,12 @@ def _send_config_modified_signal(self, action):
697746 config = self ,
698747 device = self .device ,
699748 )
749+ cache .set (
750+ self ._config_modified_timeout_cache_key ,
751+ True ,
752+ timeout = self ._CONFIG_MODIFIED_TIMEOUT ,
753+ )
754+ self ._config_modified_action = "config_changed"
700755
701756 def _send_config_deactivating_signal (self ):
702757 """
0 commit comments