Coverage for  / home / runner / work / netbox-InterfaceNameRules-plugin / netbox-InterfaceNameRules-plugin / netbox-InterfaceNameRules-plugin / netbox_interface_name_rules / signals.py: 100%

109 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 15:19 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> 

3"""Django signals for automatic interface renaming on module install.""" 

4 

5import functools 

6import logging 

7 

8from django.db import transaction 

9from django.db.models.signals import post_save, pre_save 

10from django.dispatch import receiver 

11 

12logger = logging.getLogger("netbox_interface_name_rules") 

13 

14# NOTE: We intentionally do NOT hook into pre_save on dcim.Interface. 

15# 

16# NetBox's Module.save() creates interfaces via bulk_create() which bypasses 

17# pre_save signals entirely. NetBox then manually dispatches post_save for 

18# the Module object. The actual renaming path is therefore: 

19# 

20# Module.save() 

21# → bulk_create() all interfaces (pre_save never fires) 

22# → NetBox fires post_save(Module) 

23# → on_module_saved → transaction.on_commit 

24# → _apply_rules_deferred → apply_interface_name_rules() 

25# 

26# Adding a pre_save on Interface would be a no-op for the normal install 

27# path and would create a false sense of security. 

28 

29 

30@receiver(pre_save, sender="dcim.Module", dispatch_uid="interface_name_rules_pre_save_module") 

31def on_module_pre_save(sender, instance, **kwargs): 

32 """Capture old module_type_id before a Module save (for type-change detection).""" 

33 if instance.pk is None: 

34 instance._prev_module_type_id = None 

35 return 

36 try: 

37 old = sender.objects.filter(pk=instance.pk).values("module_type_id").first() 

38 instance._prev_module_type_id = old["module_type_id"] if old else None 

39 except Exception: 

40 logger.warning("Failed to capture previous module_type_id for Module pk=%s", instance.pk, exc_info=True) 

41 instance._prev_module_type_id = None 

42 

43 

44@receiver(post_save, sender="dcim.Module", dispatch_uid="interface_name_rules_post_save_module") 

45def on_module_saved(sender, instance, created, **kwargs): 

46 """Apply interface name rules after module install or module-type change. 

47 

48 Primary renaming path: NetBox's Module.save() creates interfaces via 

49 bulk_create() and then manually dispatches post_save for the Module. 

50 This handler defers the actual renaming to on_commit so that all 

51 interfaces are visible in the DB before apply_interface_name_rules() runs. 

52 

53 Also handles module-type changes: when an existing module's type is changed 

54 via the API (PATCH), force-reapply rules to rename existing interfaces 

55 according to the new module type's rule. 

56 """ 

57 module = instance 

58 module_bay = getattr(module, "module_bay", None) 

59 if not module_bay: 

60 return 

61 

62 if created: 

63 callback = functools.partial(_apply_rules_deferred, module.pk, module_bay.pk) 

64 transaction.on_commit(callback) 

65 return 

66 

67 # Module type change: re-apply rules with force_reapply=True 

68 prev_module_type_id = getattr(instance, "_prev_module_type_id", None) 

69 if prev_module_type_id is None: 

70 return 

71 if prev_module_type_id == instance.module_type_id: 

72 return 

73 

74 logger.debug( 

75 "Module %s type changed (%s → %s) — scheduling rule re-apply", 

76 instance.pk, 

77 prev_module_type_id, 

78 instance.module_type_id, 

79 ) 

80 callback = functools.partial(_apply_rules_deferred, module.pk, module_bay.pk, force_reapply=True) 

81 transaction.on_commit(callback) 

82 

83 

84def _apply_rules_deferred(module_pk, module_bay_pk, force_reapply=False): 

85 """Apply interface name rules after transaction commit. 

86 

87 Called via transaction.on_commit so all interfaces created by bulk_create() 

88 are visible in the DB before renaming begins. 

89 

90 Args: 

91 module_pk: Primary key of the Module whose interfaces should be renamed. 

92 module_bay_pk: Primary key of the ModuleBay containing the module. 

93 force_reapply: When True, re-apply even if interfaces already have 

94 the expected names (used for module-type changes). 

95 

96 """ 

97 from dcim.models import Module, ModuleBay 

98 

99 try: 

100 module = Module.objects.get(pk=module_pk) 

101 module_bay = ModuleBay.objects.get(pk=module_bay_pk) 

102 except (Module.DoesNotExist, ModuleBay.DoesNotExist): 

103 return 

104 

105 try: 

106 from .engine import apply_interface_name_rules 

107 

108 renamed = apply_interface_name_rules(module, module_bay, force_reapply=force_reapply) 

109 if renamed: 

110 logger.info( 

111 "Renamed %d interface(s) for %s in %s", 

112 renamed, 

113 module.module_type, 

114 module_bay.name, 

115 ) 

116 except Exception: 

117 logger.exception( 

118 "Failed to apply interface name rules for %s in %s", 

119 module.module_type, 

120 module_bay.name, 

121 ) 

122 

123 

124# --------------------------------------------------------------------------- 

125# Virtual Chassis membership change detection 

126# --------------------------------------------------------------------------- 

127# When a Device's vc_position or virtual_chassis changes, any module-attached 

128# interfaces with rules using {vc_position} need to be renamed. We capture 

129# the old values in pre_save (stored on the instance) and compare in post_save. 

130 

131 

132@receiver(pre_save, sender="dcim.Device", dispatch_uid="interface_name_rules_pre_save_device") 

133def on_device_pre_save(sender, instance, **kwargs): 

134 """Capture old virtual_chassis_id and vc_position before a Device save.""" 

135 if instance.pk is None: 

136 # New device — nothing to compare 

137 instance._prev_virtual_chassis_id = None 

138 instance._prev_vc_position = None 

139 return 

140 try: 

141 old = sender.objects.filter(pk=instance.pk).values("virtual_chassis_id", "vc_position").first() 

142 instance._prev_virtual_chassis_id = old["virtual_chassis_id"] if old else None 

143 instance._prev_vc_position = old["vc_position"] if old else None 

144 except Exception: 

145 logger.warning("Failed to capture previous VC state for Device pk=%s", instance.pk, exc_info=True) 

146 instance._prev_virtual_chassis_id = None 

147 instance._prev_vc_position = None 

148 

149 

150@receiver(post_save, sender="dcim.Device", dispatch_uid="interface_name_rules_post_save_device") 

151def on_device_saved(sender, instance, created, **kwargs): 

152 """Re-apply interface name rules when a device's VC membership or position changes.""" 

153 if created: 

154 return 

155 

156 prev_vc_id = getattr(instance, "_prev_virtual_chassis_id", None) 

157 prev_vc_pos = getattr(instance, "_prev_vc_position", None) 

158 new_vc_id = instance.virtual_chassis_id 

159 new_vc_pos = instance.vc_position 

160 

161 vc_id_changed = prev_vc_id != new_vc_id 

162 vc_pos_changed = prev_vc_pos != new_vc_pos 

163 

164 if not (vc_id_changed or vc_pos_changed): 

165 return 

166 

167 # Only rename when the device is (now) in a VC — if it was removed, no {vc_position} available. 

168 if new_vc_id is None: 

169 logger.debug( 

170 "Device %s removed from VC — skipping interface rename (no vc_position available)", 

171 instance.pk, 

172 ) 

173 return 

174 

175 callback = functools.partial(_apply_rules_for_device_deferred, instance.pk) 

176 transaction.on_commit(callback) 

177 

178 

179def _apply_rules_for_device_deferred(device_pk): 

180 """Re-apply all interface name rules for a device after a VC membership or position change. 

181 

182 Handles two categories of interfaces: 

183 - Module-attached interfaces: re-applies module-level rules with force_reapply=True 

184 so {vc_position} is updated even when the interface name already matches the template. 

185 - Device-level interfaces (module=None): re-applies device-level rules which use 

186 applies_to_device_interfaces=True rules matched by interface-name regex. 

187 

188 Args: 

189 device_pk: Primary key of the Device to re-apply rules for. 

190 

191 """ 

192 from dcim.models import Device, Module 

193 

194 try: 

195 device = Device.objects.select_related("virtual_chassis").get(pk=device_pk) 

196 except Device.DoesNotExist: 

197 return 

198 

199 total = 0 

200 

201 # Re-apply module interface rules 

202 modules = Module.objects.filter(device=device).select_related( 

203 "module_bay", 

204 "module_type", 

205 "device", 

206 "device__device_type", 

207 "device__platform", 

208 "device__virtual_chassis", 

209 ) 

210 try: 

211 from .engine import apply_interface_name_rules 

212 

213 for module in modules: 

214 module_bay = module.module_bay 

215 if not module_bay: 

216 continue 

217 try: 

218 renamed = apply_interface_name_rules(module, module_bay, force_reapply=True) 

219 total += renamed or 0 

220 except Exception: 

221 logger.exception( 

222 "Failed to re-apply rules for %s in %s after VC change", 

223 module.module_type, 

224 module_bay.name, 

225 ) 

226 except Exception: 

227 logger.exception("Failed to re-apply module rules for device %s after VC change", device_pk) 

228 

229 # Re-apply device-level interface rules (interfaces with module=None) 

230 try: 

231 from .engine import apply_device_interface_rules 

232 

233 renamed = apply_device_interface_rules(device) 

234 total += renamed or 0 

235 except Exception: 

236 logger.exception("Failed to re-apply device interface rules for device %s after VC change", device_pk) 

237 

238 if total: 

239 logger.info( 

240 "Re-renamed %d interface(s) for device %s after VC change", 

241 total, 

242 device, 

243 )