Coverage for dcim/models/module_moves.py: 9%
485 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from dataclasses import dataclass
3from django.apps import apps
4from django.conf import settings
5from django.core.exceptions import ValidationError
6from django.db import router
7from django.db.models import Q
8from django.db.models.signals import post_save
9from django.utils import timezone
10from django.utils.translation import gettext as _
11from django.utils.translation import gettext_lazy
13from dcim.constants import MODULE_TOKEN
14from dcim.utils import (
15 get_module_bay_positions,
16 get_module_bay_raw_positions,
17 resolve_module_placeholder,
18 resolve_position_chain,
19)
20from utilities.counters import update_counter
21from utilities.exceptions import AbortRequest
22from utilities.fields import CounterCacheField
23from utilities.querysets import chunked_update
25from .device_components import (
26 CabledObjectModel,
27 ConsolePort,
28 ConsoleServerPort,
29 CoolingIntake,
30 CoolingOutflow,
31 FrontPort,
32 Interface,
33 ModuleBay,
34 PortMapping,
35 PowerOutlet,
36 PowerPort,
37 RearPort,
38)
40__all__ = (
41 'ComponentMove',
42 'ModuleMovePlan',
43)
45# Modular component models relocated during a move, mapped to the ModuleType template
46# accessor used for conservative template-derived renaming. ModuleBay is handled
47# separately (nested hierarchy, distinct uniqueness constraint).
48COMPONENT_TEMPLATE_ATTRS = {
49 ConsolePort: 'consoleporttemplates',
50 ConsoleServerPort: 'consoleserverporttemplates',
51 CoolingIntake: 'coolingintaketemplates',
52 CoolingOutflow: 'coolingoutflowtemplates',
53 FrontPort: 'frontporttemplates',
54 Interface: 'interfacetemplates',
55 PowerOutlet: 'poweroutlettemplates',
56 PowerPort: 'powerporttemplates',
57 RearPort: 'rearporttemplates',
58}
60MODULEBAY_TEMPLATE_ATTR = 'modulebaytemplates'
63@dataclass
64class ComponentMove:
65 """
66 The planned final state of a single component affected by a module move. Unchanged
67 values remain equal to the instance's current values.
68 """
69 instance: object
70 target_name: str
71 target_label: str
72 target_position: str = None # ModuleBay only
73 target_parent_id: int = None # ModuleBay only; set for the root module's direct child bays
76class ModuleMovePlan:
77 """
78 Plans and applies the relocation of an installed module (including its nested module
79 subtree) to a different module bay and/or device. Build via from_module(), then call
80 lock(), validate(), and (after the root Module row has been saved) apply_after_root_save().
81 """
83 def __init__(self, old_module, new_module):
84 self.old_module = old_module
85 self.new_module = new_module
86 self.module_model = type(old_module)
87 self.device_model = self.module_model._meta.get_field('device').related_model
88 self.old_device_id = old_module.device_id
89 self.new_device_id = new_module.device_id
90 self.new_device = new_module.device
91 self.new_bay = new_module.module_bay
92 self.cross_device = old_module.device_id != new_module.device_id
94 self.modules_by_level = [] # [[Module]]; level 0 is [old_module]
95 # Seeded with the root module's own pk (always known without a query) so that
96 # lock()'s first (pre-discovery) and second (post-discovery) membership snapshots
97 # compare equal when the root module truly has no descendants, bays, or
98 # components, keeping the common case to a single discover+lock pass.
99 self.module_pks = {old_module.pk}
100 self.moved_bays = [] # all ModuleBays owned by moved modules
101 self.components = {} # {model: [instances]} for COMPONENT_TEMPLATE_ATTRS models
102 self.component_moves = {model: [] for model in COMPONENT_TEMPLATE_ATTRS}
103 self.bay_moves = [] # [ComponentMove] for moved ModuleBays
104 self._target_resolution_failures = [] # display strings; see _record_target_failure()
105 self._template_cache = {} # {(module_type_id, template_attr): [templates]} per planning pass
106 self._planned = False # set once discovery + rename planning have run at least once
107 self._now = None
109 @classmethod
110 def from_module(cls, *, old_module, new_module):
111 """
112 Build a plan for the given move. Discovery and rename planning are NOT run here;
113 they run lazily (see _ensure_planned()) on the first call to validate(), or
114 eagerly inside lock() for the locked save path. A caller that goes on to call
115 lock() (Module._save_existing()) would otherwise pay for an unlocked discovery
116 pass that lock() immediately re-does under row locks - pure waste.
117 """
118 return cls(old_module, new_module)
120 def _ensure_planned(self):
121 """
122 Run discovery and rename planning if they have not already run for this
123 instance. lock() always (re-)discovers and (re-)plans itself under row locks, so
124 this is a no-op after lock() - it only does work for the unlocked clean() path,
125 where validate() is called directly against a freshly constructed plan.
126 """
127 if not self._planned:
128 self._discover()
129 self._plan_renames()
130 self._planned = True
132 def _discover(self):
133 """
134 Collect the moved subtree by module ownership: the root module, all ModuleBays
135 owned by moved modules (level by level), the modules installed in those bays,
136 and all non-bay components owned by any moved module.
138 Re-entrant: resets its accumulators first so a re-run (see lock()) reflects only
139 the current database state, not whatever a prior pass appended.
140 """
141 self.modules_by_level = []
142 self.moved_bays = []
143 self.components = {}
145 self.modules_by_level = [[self.old_module]]
146 visited_pks = {self.old_module.pk}
147 frontier = [self.old_module.pk]
148 while frontier:
149 level_bays = list(ModuleBay.objects.filter(module_id__in=frontier))
150 self.moved_bays.extend(level_bays)
151 child_modules = list(
152 self.module_model.objects.select_related('module_type').filter(
153 module_bay_id__in=[bay.pk for bay in level_bays]
154 )
155 )
156 # A revisited module pk means a cycle (creatable via .update(), bypassing clean()).
157 for module in child_modules:
158 if module.pk in visited_pks:
159 raise ValueError(_("Module bay hierarchy contains a cycle."))
160 visited_pks.add(module.pk)
161 frontier = [module.pk for module in child_modules]
162 if child_modules:
163 self.modules_by_level.append(child_modules)
165 self.module_pks = {module.pk for level in self.modules_by_level for module in level}
166 for model in COMPONENT_TEMPLATE_ATTRS:
167 self.components[model] = list(model.objects.filter(module_id__in=self.module_pks))
169 def _plan_renames(self):
170 """
171 Compute the planned final name/label/position for every moved component, top-down
172 so that a child module's new position context reflects its containing bay's
173 planned position. A component is renamed only when exactly one template of the
174 owning module's current type resolves to its current name in the old context.
176 Re-entrant: resets its accumulators first so a re-run reflects only the current
177 self.components/self.moved_bays, not whatever a prior pass appended.
178 """
179 self.component_moves = {model: [] for model in COMPONENT_TEMPLATE_ATTRS}
180 self.bay_moves = []
181 self._target_resolution_failures = []
182 self._template_cache = {}
184 # A target bay inside the moved subtree is rejected by validate(); do not walk its chain
185 if self.new_bay.pk in {bay.pk for bay in self.moved_bays}:
186 return
188 old_chains = {self.old_module.pk: get_module_bay_positions(self.old_module.module_bay)}
189 new_raw_chains = {self.old_module.pk: get_module_bay_raw_positions(self.new_bay)}
191 components_by_module = {model: {} for model in COMPONENT_TEMPLATE_ATTRS}
192 for model, instances in self.components.items():
193 for obj in instances:
194 components_by_module[model].setdefault(obj.module_id, []).append(obj)
195 bays_by_module = {}
196 for bay in self.moved_bays:
197 bays_by_module.setdefault(bay.module_id, []).append(bay)
198 installed_module_by_bay = {
199 module.module_bay_id: module
200 for level in self.modules_by_level[1:]
201 for module in level
202 }
204 for level in self.modules_by_level:
205 for module in level:
206 old_positions = old_chains[module.pk]
207 new_positions = resolve_position_chain(new_raw_chains[module.pk])
209 for model, template_attr in COMPONENT_TEMPLATE_ATTRS.items():
210 templates_by_old_name = self._index_templates(
211 self._cached_templates(module.module_type, template_attr), old_positions
212 )
213 for component in components_by_module[model].get(module.pk, []):
214 self.component_moves[model].append(self._plan_component(
215 component, templates_by_old_name, old_positions, new_positions
216 ))
218 bay_templates_by_old_name = self._index_templates(
219 self._cached_templates(module.module_type, MODULEBAY_TEMPLATE_ATTR), old_positions
220 )
221 for bay in bays_by_module.get(module.pk, []):
222 move = self._plan_component(
223 bay, bay_templates_by_old_name, old_positions, new_positions, include_position=True
224 )
225 if bay.module_id == self.old_module.pk:
226 move.target_parent_id = self.new_bay.pk
227 self.bay_moves.append(move)
229 # Track planned chains raw and resolve on use: the fold inherits an
230 # ancestor's {module} token from the planned position below it,
231 # exactly as a fresh get_module_bay_positions() walk will once the
232 # planned positions are stored, so planner and walker cannot diverge.
233 if (child := installed_module_by_bay.get(bay.pk)) is not None:
234 old_chains[child.pk] = get_module_bay_positions(bay)
235 new_raw_chains[child.pk] = new_raw_chains[module.pk] + [move.target_position or '']
237 def _cached_templates(self, module_type, template_attr):
238 """
239 Return the given template queryset for module_type as a list, fetched once per
240 (module_type, template_attr) pair per planning pass regardless of how many moved
241 modules share that module_type.
242 """
243 key = (module_type.pk, template_attr)
244 if key not in self._template_cache:
245 self._template_cache[key] = list(getattr(module_type, template_attr).all())
246 return self._template_cache[key]
248 def _index_templates(self, templates, old_positions):
249 """
250 Map each template's old-context resolved name to the templates producing it. A
251 name is a usable rename hint only when exactly one template produces it.
252 """
253 index = {}
254 for template in templates:
255 try:
256 resolved = self._resolve(template, template.name, old_positions, self.old_module.device)
257 except ValueError:
258 continue
259 index.setdefault(resolved, []).append(template)
260 return index
262 def _plan_component(self, component, templates_by_old_name, old_positions, new_positions,
263 include_position=False):
264 move = ComponentMove(instance=component, target_name=component.name, target_label=component.label)
265 if include_position:
266 move.target_position = component.position
268 matches = templates_by_old_name.get(component.name, ())
269 if len(matches) != 1:
270 return move
271 template = matches[0]
273 try:
274 move.target_name = self._resolve(template, template.name, new_positions, self.new_device)
275 except ValueError:
276 self._record_target_failure(component, 'name')
277 return move
279 try:
280 old_label = self._resolve(template, template.label, old_positions, self.old_module.device)
281 except ValueError:
282 old_label = None
283 if old_label is not None and component.label == old_label:
284 try:
285 move.target_label = self._resolve(template, template.label, new_positions, self.new_device)
286 except ValueError:
287 self._record_target_failure(component, 'label')
289 if include_position:
290 try:
291 old_position = self._resolve(
292 template, template.position, old_positions, self.old_module.device
293 )
294 except ValueError:
295 old_position = None
296 if old_position is not None and component.position == old_position:
297 try:
298 move.target_position = self._resolve(
299 template, template.position, new_positions, self.new_device
300 )
301 except ValueError:
302 self._record_target_failure(component, 'position')
304 return move
306 @staticmethod
307 def _resolve(template, value, positions, device):
308 """
309 Resolve {module} and {vc_position} tokens in a template value against an explicit
310 position chain and device. Raises ValueError on a token-count mismatch.
311 """
312 if MODULE_TOKEN in value:
313 value = resolve_module_placeholder(value, positions)
314 return type(template)._resolve_vc_position(value, device)
316 def lock(self):
317 """
318 Acquire row locks in deterministic order, then re-discover: FK inserts take KEY
319 SHARE on their referenced rows, so membership is stable only once every owning
320 row is locked. Loop until a re-discovery pass finds no new members, then refresh
321 the target rows and recompute the planned changes from the locked state.
322 """
323 while True:
324 self._lock_current_set()
325 locked_pks = self._membership_pks()
326 self._discover()
327 if self._membership_pks() == locked_pks:
328 break
329 self._refresh_target_state()
330 self._plan_renames()
331 self._planned = True
333 def _membership_pks(self):
334 return (
335 frozenset(self.module_pks),
336 frozenset(bay.pk for bay in self.moved_bays),
337 frozenset((model._meta.label, obj.pk) for model, objs in self.components.items() for obj in objs),
338 )
340 def _refresh_target_state(self):
341 # A concurrently deleted target bay is reported by validate(), not raised here
342 if (bay := ModuleBay.objects.filter(pk=self.new_bay.pk).first()) is not None:
343 self.new_bay = bay
344 self.new_device.refresh_from_db()
346 def _lock_current_set(self):
347 """
348 Acquire row locks in a deterministic order: devices, module bays (source
349 containing bay, target bay, moved bays), descendant modules, then moved
350 components per model. The root Module row is locked by the caller.
351 """
352 device_pks = sorted({self.old_device_id, self.new_device_id})
353 locked_devices = list(
354 self.device_model.objects.select_for_update().filter(pk__in=device_pks).order_by('pk')
355 )
356 if len(locked_devices) != len(device_pks):
357 raise AbortRequest(_("Device was deleted before the move could be saved."))
358 bay_pks = sorted({
359 self.old_module.module_bay_id, self.new_bay.pk, *(bay.pk for bay in self.moved_bays)
360 })
361 list(ModuleBay.objects.select_for_update().filter(pk__in=bay_pks).order_by('pk'))
362 descendant_pks = sorted(self.module_pks - {self.old_module.pk})
363 if descendant_pks:
364 list(self.module_model.objects.select_for_update().filter(pk__in=descendant_pks).order_by('pk'))
365 for model in sorted(self.components, key=lambda model: model._meta.label):
366 pks = sorted(obj.pk for obj in self.components[model])
367 if pks:
368 list(model.objects.select_for_update().filter(pk__in=pks).order_by('pk'))
370 # Maximum number of object names quoted when a validation error names its offenders
371 SAMPLE_LIMIT = 5
373 # Interface relations carrying topology or device-scoped configuration state which
374 # block a cross-device move
375 INTERFACE_BLOCKERS = (
376 (gettext_lazy('IP addresses assigned'), Q(ip_addresses__isnull=False)),
377 (gettext_lazy('FHRP group assignments'), Q(fhrp_group_assignments__isnull=False)),
378 (gettext_lazy('tunnel terminations'), Q(tunnel_terminations__isnull=False)),
379 (gettext_lazy('L2VPN terminations'), Q(l2vpn_terminations__isnull=False)),
380 (gettext_lazy('virtual circuit terminations'), Q(virtual_circuit_termination__isnull=False)),
381 (gettext_lazy('wireless links'), Q(wireless_link__isnull=False)),
382 (gettext_lazy('wireless LAN assignments'), Q(wireless_lans__isnull=False)),
383 (gettext_lazy('an untagged VLAN'), Q(untagged_vlan__isnull=False)),
384 (gettext_lazy('tagged VLANs'), Q(tagged_vlans__isnull=False)),
385 (gettext_lazy('a Q-in-Q service VLAN'), Q(qinq_svlan__isnull=False)),
386 (gettext_lazy('a VLAN translation policy'), Q(vlan_translation_policy__isnull=False)),
387 (gettext_lazy('VDC assignments'), Q(vdcs__isnull=False)),
388 (gettext_lazy('a VRF assignment'), Q(vrf__isnull=False)),
389 )
391 def validate(self):
392 """
393 Validate the move against current database state. Raises ValidationError with
394 all failures collected. Called unlocked from Module.clean() for UX and again
395 under row locks from Module.save(). The locked pass is authoritative for the
396 state its row locks serialize (the moved rows and FK-backed relations to
397 them). GenericForeignKey-backed relations (inventory items, IP addresses,
398 FHRP, tunnel, and L2VPN terminations) carry no database-level reference to
399 the moved rows, so a concurrent insert can still land alongside the move
400 after this check has passed; enforcing those invariants atomically is a
401 database-level follow-up.
402 """
403 self._ensure_planned()
404 errors = []
405 self._validate_target_bay(errors)
406 if self.cross_device:
407 errors.extend(self._check_cross_device_blockers())
408 errors.extend(self._check_name_conflicts())
409 errors.extend(self._check_length_violations())
410 errors.extend(self._check_target_resolution_failures())
411 if errors:
412 raise ValidationError(errors)
414 def _name_sample(self, description, *querysets):
415 """
416 Append a sample of the offending objects' names to a blocker description, so that a
417 rejected move names the components to fix rather than only counting them. Each
418 queryset is fetched with its own LIMIT and the walk stops as soon as the sample is
419 full, so the cost stays fixed no matter how many rows offend.
421 Called only from branches that have already found offenders, so a permitted move
422 pays nothing for this.
423 """
424 names = []
425 for queryset in querysets:
426 names.extend(queryset.order_by('name').values_list('name', flat=True)[:self.SAMPLE_LIMIT])
427 if len(names) >= self.SAMPLE_LIMIT:
428 break
429 # Dedupe while preserving order. The name column carries a natural_sort collation, so
430 # each queryset arrives in the order a reader expects; groups are then concatenated
431 # rather than merged, so the sample is ordered within a group but not across them, and
432 # equally named rows drawn from different models collapse into one entry. Both are
433 # acceptable in an illustrative sample and neither affects the reported count.
434 if not (sample := list(dict.fromkeys(names))[:self.SAMPLE_LIMIT]):
435 return description
436 return _("{description} (e.g. {names})").format(description=description, names=', '.join(sample))
438 def _check_cross_device_blockers(self):
439 """
440 Reject a cross-device move when any moved component carries topology or
441 device-scoped configuration state, or when a parent/bridge/LAG, power outlet,
442 or port mapping relation would cross the moved subtree's boundary in either
443 direction. Inventory items attached to a moved component also block (v1).
445 Each blocker names a sample of the offending components; see _name_sample().
446 """
447 blockers = []
448 moved_interface_pks = {obj.pk for obj in self.components[Interface]}
450 # Cabled or connection-marked components. Cooling components are not cable terminations and
451 # have no cable/mark_connected columns, so the check is driven off the model class.
452 for model, instances in self.components.items():
453 if not issubclass(model, CabledObjectModel):
454 continue
455 pks = [obj.pk for obj in instances]
456 if not pks:
457 continue
458 offenders = model.objects.filter(pk__in=pks).filter(
459 Q(cable__isnull=False) | Q(mark_connected=True)
460 )
461 if count := offenders.count():
462 blockers.append(self._name_sample(
463 _("{count} cabled or connection-marked {type}").format(
464 count=count, type=model._meta.verbose_name_plural
465 ),
466 offenders,
467 ))
469 # Interface topology/configuration state
470 for label, condition in self.INTERFACE_BLOCKERS:
471 offenders = Interface.objects.filter(pk__in=moved_interface_pks).filter(condition).distinct()
472 if count := offenders.count():
473 blockers.append(self._name_sample(
474 _("{count} interfaces with {label}").format(count=count, label=label),
475 offenders,
476 ))
478 # Parent/bridge/LAG relations crossing the moved-set boundary (either direction)
479 outward = Interface.objects.filter(pk__in=moved_interface_pks).filter(
480 Q(parent__isnull=False) & ~Q(parent_id__in=moved_interface_pks) |
481 Q(bridge__isnull=False) & ~Q(bridge_id__in=moved_interface_pks) |
482 Q(lag__isnull=False) & ~Q(lag_id__in=moved_interface_pks)
483 )
484 inward = Interface.objects.exclude(pk__in=moved_interface_pks).filter(
485 Q(parent_id__in=moved_interface_pks) |
486 Q(bridge_id__in=moved_interface_pks) |
487 Q(lag_id__in=moved_interface_pks)
488 )
489 outward_count, inward_count = outward.count(), inward.count()
490 if outward_count or inward_count:
491 blockers.append(self._name_sample(
492 _(
493 "{count} parent, bridge, or LAG interface relations crossing the moved module's boundary"
494 ).format(count=outward_count + inward_count),
495 outward, inward,
496 ))
498 # Power outlet to power port relations crossing the boundary
499 moved_outlet_pks = {obj.pk for obj in self.components[PowerOutlet]}
500 moved_power_port_pks = {obj.pk for obj in self.components[PowerPort]}
501 split_outlets = PowerOutlet.objects.filter(
502 pk__in=moved_outlet_pks, power_port__isnull=False
503 ).exclude(power_port_id__in=moved_power_port_pks)
504 adopted_outlets = PowerOutlet.objects.exclude(pk__in=moved_outlet_pks).filter(
505 power_port_id__in=moved_power_port_pks
506 )
507 split_power = split_outlets.count() + adopted_outlets.count()
508 if split_power:
509 blockers.append(self._name_sample(
510 _(
511 "{count} power outlet relations crossing the moved module's boundary"
512 ).format(count=split_power),
513 split_outlets, adopted_outlets,
514 ))
516 # Cooling outflow to cooling intake relations crossing the boundary. Only this direction is
517 # device-scoped (CoolingOutflow.clean() requires an intake on the same device); an intake's
518 # upstream CoolingOutflow is routinely supplied by another device, such as a CDU, and so is
519 # deliberately left alone.
520 moved_intake_pks = {obj.pk for obj in self.components[CoolingIntake]}
521 moved_outflow_pks = {obj.pk for obj in self.components[CoolingOutflow]}
522 split_outflows = CoolingOutflow.objects.filter(
523 pk__in=moved_outflow_pks, cooling_intake__isnull=False
524 ).exclude(cooling_intake_id__in=moved_intake_pks)
525 adopted_outflows = CoolingOutflow.objects.exclude(pk__in=moved_outflow_pks).filter(
526 cooling_intake_id__in=moved_intake_pks
527 )
528 split_cooling = split_outflows.count() + adopted_outflows.count()
529 if split_cooling:
530 blockers.append(self._name_sample(
531 _(
532 "{count} cooling outflow relations crossing the moved module's boundary"
533 ).format(count=split_cooling),
534 split_outflows, adopted_outflows,
535 ))
537 # Front/rear port mappings crossing the boundary
538 moved_front_port_pks = {obj.pk for obj in self.components[FrontPort]}
539 moved_rear_port_pks = {obj.pk for obj in self.components[RearPort]}
540 split_fronts = PortMapping.objects.filter(
541 front_port_id__in=moved_front_port_pks
542 ).exclude(rear_port_id__in=moved_rear_port_pks)
543 split_rears = PortMapping.objects.filter(
544 rear_port_id__in=moved_rear_port_pks
545 ).exclude(front_port_id__in=moved_front_port_pks)
546 split_mappings = split_fronts.count() + split_rears.count()
547 if split_mappings:
548 blockers.append(self._name_sample(
549 _(
550 "{count} front/rear port mappings crossing the moved module's boundary"
551 ).format(count=split_mappings),
552 # PortMapping has no name of its own, so name the moved port on each side of the
553 # boundary: the front port when its rear port stays behind, and the rear port when
554 # its front port does. Naming the non-moved end instead would point the user at a
555 # component they will not find on the module they are moving.
556 FrontPort.objects.filter(pk__in=split_fronts.values('front_port_id')),
557 RearPort.objects.filter(pk__in=split_rears.values('rear_port_id')),
558 ))
560 # Attached inventory items (blocked in v1)
561 item_querysets = []
562 for model, instances in self.components.items():
563 if pks := [obj.pk for obj in instances]:
564 item_querysets.append(
565 model.objects.filter(pk__in=pks, inventory_items__isnull=False).distinct()
566 )
567 if bay_pks := [bay.pk for bay in self.moved_bays]:
568 item_querysets.append(
569 ModuleBay.objects.filter(pk__in=bay_pks, inventory_items__isnull=False).distinct()
570 )
571 if item_count := sum(queryset.count() for queryset in item_querysets):
572 blockers.append(self._name_sample(
573 _("{count} components with attached inventory items").format(count=item_count),
574 *item_querysets,
575 ))
577 if not blockers:
578 return []
579 return [
580 _(
581 "This module cannot be moved to a different device because the moved components have "
582 "active related objects: {blockers}."
583 ).format(blockers='; '.join(str(blocker) for blocker in blockers))
584 ]
586 def _check_name_conflicts(self):
587 errors = []
588 for model, moves in self.component_moves.items():
589 if not moves:
590 continue
591 seen = set()
592 for move in moves:
593 if move.target_name in seen:
594 errors.append(
595 _("Moving this module would create more than one {type} named {name}.").format(
596 type=model._meta.verbose_name, name=move.target_name
597 )
598 )
599 seen.add(move.target_name)
600 conflict_qs = model.objects.filter(
601 device_id=self.new_device_id, name__in=seen
602 ).exclude(pk__in=[move.instance.pk for move in moves])
603 if count := conflict_qs.count():
604 sample = ', '.join(
605 conflict_qs.order_by('name').values_list('name', flat=True)[:self.SAMPLE_LIMIT]
606 )
607 errors.append(
608 _(
609 "Moving this module would conflict with {count} existing {type} on device "
610 "{device} (e.g. {sample})."
611 ).format(
612 count=count, type=model._meta.verbose_name_plural,
613 device=self.new_device, sample=sample
614 )
615 )
616 if not self.cross_device:
617 current_names = {move.instance.name for move in moves}
618 for move in moves:
619 if move.target_name != move.instance.name and move.target_name in current_names:
620 errors.append(
621 _(
622 "Moving this module would rename {old_name} to {new_name}, which is the "
623 "current name of another moved {type}. Rename the affected components "
624 "manually before moving."
625 ).format(
626 old_name=move.instance.name,
627 new_name=move.target_name,
628 type=model._meta.verbose_name,
629 )
630 )
631 # ModuleBay names are unique per (device, module, name); moved bays keep their
632 # module assignment, so conflicts are only possible within the moved set
633 seen_bays = set()
634 for move in self.bay_moves:
635 key = (move.instance.module_id, move.target_name)
636 if key in seen_bays:
637 errors.append(
638 _(
639 "Moving this module would create more than one module bay named {name} "
640 "within the same module."
641 ).format(name=move.target_name)
642 )
643 seen_bays.add(key)
644 if not self.cross_device:
645 current_bay_keys = {(move.instance.module_id, move.instance.name) for move in self.bay_moves}
646 for move in self.bay_moves:
647 if move.target_name != move.instance.name and (
648 (move.instance.module_id, move.target_name) in current_bay_keys
649 ):
650 errors.append(
651 _(
652 "Moving this module would rename module bay {old_name} to {new_name}, which is "
653 "the current name of another moved module bay in the same module."
654 ).format(old_name=move.instance.name, new_name=move.target_name)
655 )
656 return errors
658 def _check_length_violations(self):
659 """
660 Reject a move whose planned rename would exceed the destination field's
661 max_length, rather than deferring to a mid-apply DataError from bulk_update().
662 Limits are read from model meta so a future field-length change stays correct
663 without editing this method.
664 """
665 offenders = []
666 for model, moves in self.component_moves.items():
667 name_limit = model._meta.get_field('name').max_length
668 label_limit = model._meta.get_field('label').max_length
669 for move in moves:
670 offenders.extend(self._length_offenders(move, name=name_limit, label=label_limit))
671 name_limit = ModuleBay._meta.get_field('name').max_length
672 label_limit = ModuleBay._meta.get_field('label').max_length
673 position_limit = ModuleBay._meta.get_field('position').max_length
674 for move in self.bay_moves:
675 offenders.extend(
676 self._length_offenders(move, name=name_limit, label=label_limit, position=position_limit)
677 )
678 if not offenders:
679 return []
680 return [
681 _("Moving this module would exceed the maximum field length for the following: {offenders}.").format(
682 offenders='; '.join(offenders)
683 )
684 ]
686 def _length_offenders(self, move, **limits):
687 """
688 Return one display string per (field, value) pair on move whose length exceeds
689 the given limit. limits maps a field name ('name', 'label', and 'position' for
690 module bays) to the destination model's max_length for that field.
691 """
692 values = {'name': move.target_name, 'label': move.target_label, 'position': move.target_position or ''}
693 offenders = []
694 for field, limit in limits.items():
695 value = values[field]
696 if len(value) > limit:
697 offenders.append(
698 _("{component}: new {field} {value} ({length} characters) exceeds the "
699 "{limit}-character limit").format(
700 component=move.instance, field=field, value=self._truncate_for_display(value),
701 length=len(value), limit=limit,
702 )
703 )
704 return offenders
706 @staticmethod
707 def _truncate_for_display(value, limit=40):
708 if len(value) <= limit:
709 return value
710 return f'{value[:limit]}...'
712 def _record_target_failure(self, component, field):
713 """
714 Record a component whose source value matched a template that cannot be
715 resolved for the destination; reported collectively by validate().
716 """
717 self._target_resolution_failures.append(
718 _("{component}: the matched template's {field} cannot be resolved for the destination "
719 "bay hierarchy").format(component=component, field=field)
720 )
722 def _check_target_resolution_failures(self):
723 if not self._target_resolution_failures:
724 return []
725 return [
726 _(
727 "Moving this module would require template-derived values that cannot be resolved for "
728 "the destination bay hierarchy: {failures}. Choose a destination at a compatible "
729 "nesting depth or rename the affected components manually before moving."
730 ).format(failures='; '.join(self._target_resolution_failures))
731 ]
733 def _validate_target_bay(self, errors):
734 bay = ModuleBay.objects.filter(pk=self.new_bay.pk).first()
735 if bay is None:
736 errors.append(_("The target module bay no longer exists."))
737 return
738 if bay.device_id != self.new_device_id:
739 errors.append(
740 _("Module bay {module_bay} does not belong to device {device}.").format(
741 module_bay=bay, device=self.new_device
742 )
743 )
744 if not bay.enabled:
745 errors.append(_("Cannot install a module in a disabled module bay."))
746 if occupant := self.module_model.objects.filter(
747 module_bay_id=bay.pk
748 ).exclude(pk=self.old_module.pk).first():
749 errors.append(
750 _("Module bay {module_bay} is already occupied by module {module}.").format(
751 module_bay=bay, module=occupant
752 )
753 )
754 if bay.pk in {moved_bay.pk for moved_bay in self.moved_bays}:
755 errors.append(_("A module bay cannot belong to a module installed within it."))
757 def apply_after_root_save(self):
758 """
759 Apply the planned updates after the root Module row has been saved: descendant
760 modules, then module bays (parent re-pointing; ltree triggers recompute
761 path/sort_path), then components, port mappings, and device counters, with
762 manual post_save emission for changelog/search side effects.
763 """
764 self._now = timezone.now()
765 self._apply_descendant_modules()
766 self._apply_bays()
767 self._apply_components()
768 self._apply_port_mappings()
769 self._recompute_counters()
771 def _apply_descendant_modules(self):
772 if not self.cross_device:
773 return
774 descendants = [module for level in self.modules_by_level[1:] for module in level]
775 if not descendants:
776 return
777 for module in descendants:
778 module.snapshot()
779 module.device_id = self.new_device_id
780 module.last_updated = self._now
781 self.module_model.objects.bulk_update(
782 descendants, ['device', 'last_updated'], batch_size=settings.BULK_UPDATE_CHUNK_SIZE
783 )
784 self._send_post_saves(self.module_model, descendants, ['device', 'last_updated'])
786 def _apply_bays(self):
787 """
788 Persist planned bay changes in four stages so that ltree hierarchy columns
789 (parent) and naming columns (name/position/label) never share a bulk_update
790 statement across overlapping subtrees; see utilities/ltree.py for the trigger
791 behavior this must respect (BEFORE on parent_id/name; AFTER cascade on the same).
792 A cross-device move's device/_site/_location/_rack fields are written in the same
793 per-row statement as any rename below, so a bay's (device, name) pair changes as
794 one atomic write and is never transiently mismatched against either device.
795 """
796 bay_changes = [] # [(bay, changed_fields)]
797 for move in self.bay_moves:
798 bay = move.instance
799 changed = []
800 if move.target_parent_id is not None and bay.parent_id != move.target_parent_id:
801 changed.append('parent')
802 if bay.name != move.target_name:
803 changed.append('name')
804 if bay.label != move.target_label:
805 changed.append('label')
806 if move.target_position is not None and bay.position != move.target_position:
807 changed.append('position')
808 if self.cross_device:
809 changed.extend(['device', '_site', '_location', '_rack'])
810 if not changed:
811 continue
812 bay.snapshot()
813 if self.cross_device:
814 bay.device_id = self.new_device_id
815 bay._site = self.new_device.site
816 bay._location = self.new_device.location
817 bay._rack = self.new_device.rack
818 if 'parent' in changed:
819 bay.parent_id = move.target_parent_id
820 bay.name = move.target_name
821 bay.label = move.target_label
822 if move.target_position is not None:
823 bay.position = move.target_position
824 bay.last_updated = self._now
825 bay_changes.append((bay, changed))
827 if not bay_changes:
828 return
830 # Stage 1: parent-only, for the root's direct child bays being reparented.
831 reparented = [bay for bay, changed in bay_changes if 'parent' in changed]
832 if reparented:
833 ModuleBay.objects.bulk_update(reparented, ['parent'], batch_size=settings.BULK_UPDATE_CHUNK_SIZE)
835 # Stage 2: renames, level-by-level top-down. Same-level bays are disjoint
836 # subtrees, so per-level statements cannot overlap, and level N's AFTER-trigger
837 # cascade settles descendant sort_paths before level N+1's statement runs.
838 # Cross-device device/_site/_location/_rack fields ride along in the same statement.
839 level_by_module_pk = {
840 module.pk: level_index
841 for level_index, level in enumerate(self.modules_by_level)
842 for module in level
843 }
844 renames_by_level = {}
845 for bay, changed in bay_changes:
846 level_fields = [
847 field for field in ('name', 'position', 'label', 'device', '_site', '_location', '_rack')
848 if field in changed
849 ]
850 if not level_fields:
851 continue
852 level_index = level_by_module_pk[bay.module_id]
853 renames_by_level.setdefault(level_index, []).append((bay, level_fields))
854 for level_index in sorted(renames_by_level):
855 level_bays = renames_by_level[level_index]
856 fields = sorted({field for _bay, bay_fields in level_bays for field in bay_fields})
857 ModuleBay.objects.bulk_update(
858 [bay for bay, _field in level_bays], fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
859 )
861 # Stage 3: one scalar statement for every changed bay; never parent/name here.
862 updated = [bay for bay, _ in bay_changes]
863 ModuleBay.objects.bulk_update(updated, ['last_updated'], batch_size=settings.BULK_UPDATE_CHUNK_SIZE)
865 # Stage 4: sync in-memory ltree columns, then emit post_save per bay with the
866 # union of its own changed fields (fields differ per bay, so one call each).
867 self._refresh_ltree_columns(updated)
868 for bay, changed in bay_changes:
869 self._send_post_saves(ModuleBay, [bay], sorted({*changed, 'last_updated'}))
871 def _apply_components(self):
872 for model, moves in self.component_moves.items():
873 updated = []
874 update_fields = set()
875 for move in moves:
876 component = move.instance
877 changed = []
878 if component.name != move.target_name:
879 changed.append('name')
880 if component.label != move.target_label:
881 changed.append('label')
882 if self.cross_device:
883 changed.extend(['device', '_site', '_location', '_rack'])
884 if not changed:
885 continue
886 component.snapshot()
887 if self.cross_device:
888 component.device_id = self.new_device_id
889 component._site = self.new_device.site
890 component._location = self.new_device.location
891 component._rack = self.new_device.rack
892 component.name = move.target_name
893 component.label = move.target_label
894 component.last_updated = self._now
895 updated.append(component)
896 update_fields.update(changed)
897 if not updated:
898 continue
899 fields = set(update_fields)
900 if model is Interface and 'name' in update_fields:
901 name_field = Interface._meta.get_field('_name')
902 for component in updated:
903 name_field.pre_save(component, False)
904 fields.add('_name')
905 fields.add('last_updated')
906 fields = sorted(fields)
907 model.objects.bulk_update(updated, fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE)
908 self._send_post_saves(model, updated, fields)
910 def _apply_port_mappings(self):
911 # Private model, no changelog or last_updated field; mirrors PortMapping.save()'s device derivation.
912 if not self.cross_device:
913 return
914 moved_front_port_pks = [obj.pk for obj in self.components[FrontPort]]
915 moved_rear_port_pks = [obj.pk for obj in self.components[RearPort]]
916 if moved_front_port_pks and moved_rear_port_pks:
917 chunked_update(
918 PortMapping.objects.filter(
919 front_port_id__in=moved_front_port_pks,
920 rear_port_id__in=moved_rear_port_pks,
921 ),
922 device_id=self.new_device_id,
923 )
925 @staticmethod
926 def device_counters_by_model(device_model):
927 """
928 Map each model counted by a device-scoped counter cache on Device to that counter's
929 field name. Derived from Device's own field declarations so that a modular component
930 model added to COMPONENT_TEMPLATE_ATTRS cannot silently skip counter recomputation -
931 a drift which raises nothing and only shows up as a wrong count on two devices.
932 """
933 return {
934 apps.get_model(field.to_model_name): field.name
935 for field in device_model._meta.get_fields()
936 if isinstance(field, CounterCacheField) and field.to_field_name == 'device'
937 }
939 def _moved_row_counts(self):
940 """
941 Moved row counts keyed by model, covering everything this plan relocates. ModuleBay is
942 tracked outside self.components (see _discover()), so it is added back here.
943 """
944 counts = {model: len(instances) for model, instances in self.components.items()}
945 counts[ModuleBay] = len(self.moved_bays)
946 return counts
948 def _recompute_counters(self):
949 # bulk updates bypass the signal-driven counters; apply exact deltas for both devices
950 if not self.cross_device:
951 return
952 counts = self._moved_row_counts()
953 for model, counter in self.device_counters_by_model(self.device_model).items():
954 if count := counts.get(model, 0):
955 update_counter(self.device_model, self.old_device_id, counter, -count)
956 update_counter(self.device_model, self.new_device_id, counter, count)
958 def _refresh_ltree_columns(self, bays):
959 """
960 bulk_update fires the DB triggers that rewrite path/sort_path, but the in-memory
961 instances keep stale values which would leak into changelog snapshots.
962 """
963 refreshed = {
964 row['pk']: row
965 for row in ModuleBay.objects.filter(pk__in=[bay.pk for bay in bays]).values(
966 'pk', 'path', 'sort_path'
967 )
968 }
969 for bay in bays:
970 bay.path = refreshed[bay.pk]['path']
971 bay.sort_path = refreshed[bay.pk]['sort_path']
973 @staticmethod
974 def _send_post_saves(model, instances, update_fields):
975 for instance in instances:
976 # Clear tracked counter state so the incremental counter receiver no-ops;
977 # counters are recomputed explicitly for cross-device moves.
978 instance.tracker.clear()
979 post_save.send(
980 sender=model,
981 instance=instance,
982 created=False,
983 raw=False,
984 using=router.db_for_write(model),
985 update_fields=update_fields,
986 )