Bläddra i källkod

#15289: Pre-release QA (#22897)

* Add support for liquid cooling components

* Include sample of offending components when module move is disallowed

* Use settings.BULK_UPDATE_CHUNK_SIZE for batch_size

* Adopt review feedback
Jeremy Stretch 1 vecka sedan
förälder
incheckning
bfb665ccb9

+ 3 - 1
docs/models/dcim/module.md

@@ -10,7 +10,9 @@ An installed module can be moved to a different module bay after creation. The d
 
 
 Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay's nesting depth.
 Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay's nesting depth.
 
 
-Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces.
+Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, cooling outflow to cooling intake, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces.
+
+A cooling intake's upstream [cooling outflow](./coolingoutflow.md) is not device-scoped — an intake is routinely supplied by an outflow on another device, such as a CDU — so that assignment is preserved across a cross-device move rather than blocking it.
 
 
 Via the REST API, a module can be moved by patching only `module_bay`; the device is derived from the target bay. Changing a module's type and moving it must be performed as separate operations.
 Via the REST API, a module can be moved by patching only `module_bay`; the device is derived from the target bay. Changing a module's type and moving it must be performed as separate operations.
 
 

+ 2 - 0
netbox/dcim/api/serializers_/devices.py

@@ -260,6 +260,8 @@ class ModuleSerializer(PrimaryModelSerializer):
             ('interfacetemplates', 'interfaces'),
             ('interfacetemplates', 'interfaces'),
             ('powerporttemplates', 'powerports'),
             ('powerporttemplates', 'powerports'),
             ('poweroutlettemplates', 'poweroutlets'),
             ('poweroutlettemplates', 'poweroutlets'),
+            ('coolingintaketemplates', 'coolingintakes'),
+            ('coolingoutflowtemplates', 'coolingoutflows'),
             ('rearporttemplates', 'rearports'),
             ('rearporttemplates', 'rearports'),
             ('frontporttemplates', 'frontports'),
             ('frontporttemplates', 'frontports'),
         ]:
         ]:

+ 2 - 0
netbox/dcim/forms/common.py

@@ -154,6 +154,8 @@ class ModuleCommonForm(forms.Form):
                 ("interfacetemplates", "interfaces"),
                 ("interfacetemplates", "interfaces"),
                 ("powerporttemplates", "powerports"),
                 ("powerporttemplates", "powerports"),
                 ("poweroutlettemplates", "poweroutlets"),
                 ("poweroutlettemplates", "poweroutlets"),
+                ("coolingintaketemplates", "coolingintakes"),
+                ("coolingoutflowtemplates", "coolingoutflows"),
                 ("rearporttemplates", "rearports"),
                 ("rearporttemplates", "rearports"),
                 ("frontporttemplates", "frontports")
                 ("frontporttemplates", "frontports")
         ]:
         ]:

+ 164 - 62
netbox/dcim/models/module_moves.py

@@ -1,5 +1,7 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
 
 
+from django.apps import apps
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import router
 from django.db import router
 from django.db.models import Q
 from django.db.models import Q
@@ -17,11 +19,15 @@ from dcim.utils import (
 )
 )
 from utilities.counters import update_counter
 from utilities.counters import update_counter
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
+from utilities.fields import CounterCacheField
 from utilities.querysets import chunked_update
 from utilities.querysets import chunked_update
 
 
 from .device_components import (
 from .device_components import (
+    CabledObjectModel,
     ConsolePort,
     ConsolePort,
     ConsoleServerPort,
     ConsoleServerPort,
+    CoolingIntake,
+    CoolingOutflow,
     FrontPort,
     FrontPort,
     Interface,
     Interface,
     ModuleBay,
     ModuleBay,
@@ -36,14 +42,14 @@ __all__ = (
     'ModuleMovePlan',
     'ModuleMovePlan',
 )
 )
 
 
-BATCH_SIZE = 1000
-
 # Modular component models relocated during a move, mapped to the ModuleType template
 # Modular component models relocated during a move, mapped to the ModuleType template
 # accessor used for conservative template-derived renaming. ModuleBay is handled
 # accessor used for conservative template-derived renaming. ModuleBay is handled
 # separately (nested hierarchy, distinct uniqueness constraint).
 # separately (nested hierarchy, distinct uniqueness constraint).
 COMPONENT_TEMPLATE_ATTRS = {
 COMPONENT_TEMPLATE_ATTRS = {
     ConsolePort: 'consoleporttemplates',
     ConsolePort: 'consoleporttemplates',
     ConsoleServerPort: 'consoleserverporttemplates',
     ConsoleServerPort: 'consoleserverporttemplates',
+    CoolingIntake: 'coolingintaketemplates',
+    CoolingOutflow: 'coolingoutflowtemplates',
     FrontPort: 'frontporttemplates',
     FrontPort: 'frontporttemplates',
     Interface: 'interfacetemplates',
     Interface: 'interfacetemplates',
     PowerOutlet: 'poweroutlettemplates',
     PowerOutlet: 'poweroutlettemplates',
@@ -361,6 +367,9 @@ class ModuleMovePlan:
             if pks:
             if pks:
                 list(model.objects.select_for_update().filter(pk__in=pks).order_by('pk'))
                 list(model.objects.select_for_update().filter(pk__in=pks).order_by('pk'))
 
 
+    # Maximum number of object names quoted when a validation error names its offenders
+    SAMPLE_LIMIT = 5
+
     # Interface relations carrying topology or device-scoped configuration state which
     # Interface relations carrying topology or device-scoped configuration state which
     # block a cross-device move
     # block a cross-device move
     INTERFACE_BLOCKERS = (
     INTERFACE_BLOCKERS = (
@@ -402,95 +411,168 @@ class ModuleMovePlan:
         if errors:
         if errors:
             raise ValidationError(errors)
             raise ValidationError(errors)
 
 
+    def _name_sample(self, description, *querysets):
+        """
+        Append a sample of the offending objects' names to a blocker description, so that a
+        rejected move names the components to fix rather than only counting them. Each
+        queryset is fetched with its own LIMIT and the walk stops as soon as the sample is
+        full, so the cost stays fixed no matter how many rows offend.
+
+        Called only from branches that have already found offenders, so a permitted move
+        pays nothing for this.
+        """
+        names = []
+        for queryset in querysets:
+            names.extend(queryset.order_by('name').values_list('name', flat=True)[:self.SAMPLE_LIMIT])
+            if len(names) >= self.SAMPLE_LIMIT:
+                break
+        # Dedupe while preserving order. The name column carries a natural_sort collation, so
+        # each queryset arrives in the order a reader expects; groups are then concatenated
+        # rather than merged, so the sample is ordered within a group but not across them, and
+        # equally named rows drawn from different models collapse into one entry. Both are
+        # acceptable in an illustrative sample and neither affects the reported count.
+        if not (sample := list(dict.fromkeys(names))[:self.SAMPLE_LIMIT]):
+            return description
+        return _("{description} (e.g. {names})").format(description=description, names=', '.join(sample))
+
     def _check_cross_device_blockers(self):
     def _check_cross_device_blockers(self):
         """
         """
         Reject a cross-device move when any moved component carries topology or
         Reject a cross-device move when any moved component carries topology or
         device-scoped configuration state, or when a parent/bridge/LAG, power outlet,
         device-scoped configuration state, or when a parent/bridge/LAG, power outlet,
         or port mapping relation would cross the moved subtree's boundary in either
         or port mapping relation would cross the moved subtree's boundary in either
         direction. Inventory items attached to a moved component also block (v1).
         direction. Inventory items attached to a moved component also block (v1).
+
+        Each blocker names a sample of the offending components; see _name_sample().
         """
         """
         blockers = []
         blockers = []
         moved_interface_pks = {obj.pk for obj in self.components[Interface]}
         moved_interface_pks = {obj.pk for obj in self.components[Interface]}
 
 
-        # Cabled or connection-marked components
+        # Cabled or connection-marked components. Cooling components are not cable terminations and
+        # have no cable/mark_connected columns, so the check is driven off the model class.
         for model, instances in self.components.items():
         for model, instances in self.components.items():
+            if not issubclass(model, CabledObjectModel):
+                continue
             pks = [obj.pk for obj in instances]
             pks = [obj.pk for obj in instances]
             if not pks:
             if not pks:
                 continue
                 continue
-            count = model.objects.filter(pk__in=pks).filter(
+            offenders = model.objects.filter(pk__in=pks).filter(
                 Q(cable__isnull=False) | Q(mark_connected=True)
                 Q(cable__isnull=False) | Q(mark_connected=True)
-            ).count()
-            if count:
-                blockers.append(_("{count} cabled or connection-marked {type}").format(
-                    count=count, type=model._meta.verbose_name_plural
+            )
+            if count := offenders.count():
+                blockers.append(self._name_sample(
+                    _("{count} cabled or connection-marked {type}").format(
+                        count=count, type=model._meta.verbose_name_plural
+                    ),
+                    offenders,
                 ))
                 ))
 
 
         # Interface topology/configuration state
         # Interface topology/configuration state
         for label, condition in self.INTERFACE_BLOCKERS:
         for label, condition in self.INTERFACE_BLOCKERS:
-            count = Interface.objects.filter(pk__in=moved_interface_pks).filter(
-                condition
-            ).distinct().count()
-            if count:
-                blockers.append(_("{count} interfaces with {label}").format(count=count, label=label))
+            offenders = Interface.objects.filter(pk__in=moved_interface_pks).filter(condition).distinct()
+            if count := offenders.count():
+                blockers.append(self._name_sample(
+                    _("{count} interfaces with {label}").format(count=count, label=label),
+                    offenders,
+                ))
 
 
         # Parent/bridge/LAG relations crossing the moved-set boundary (either direction)
         # Parent/bridge/LAG relations crossing the moved-set boundary (either direction)
         outward = Interface.objects.filter(pk__in=moved_interface_pks).filter(
         outward = Interface.objects.filter(pk__in=moved_interface_pks).filter(
             Q(parent__isnull=False) & ~Q(parent_id__in=moved_interface_pks) |
             Q(parent__isnull=False) & ~Q(parent_id__in=moved_interface_pks) |
             Q(bridge__isnull=False) & ~Q(bridge_id__in=moved_interface_pks) |
             Q(bridge__isnull=False) & ~Q(bridge_id__in=moved_interface_pks) |
             Q(lag__isnull=False) & ~Q(lag_id__in=moved_interface_pks)
             Q(lag__isnull=False) & ~Q(lag_id__in=moved_interface_pks)
-        ).count()
+        )
         inward = Interface.objects.exclude(pk__in=moved_interface_pks).filter(
         inward = Interface.objects.exclude(pk__in=moved_interface_pks).filter(
             Q(parent_id__in=moved_interface_pks) |
             Q(parent_id__in=moved_interface_pks) |
             Q(bridge_id__in=moved_interface_pks) |
             Q(bridge_id__in=moved_interface_pks) |
             Q(lag_id__in=moved_interface_pks)
             Q(lag_id__in=moved_interface_pks)
-        ).count()
-        if outward or inward:
-            blockers.append(_(
-                "{count} parent, bridge, or LAG interface relations crossing the moved module's boundary"
-            ).format(count=outward + inward))
+        )
+        outward_count, inward_count = outward.count(), inward.count()
+        if outward_count or inward_count:
+            blockers.append(self._name_sample(
+                _(
+                    "{count} parent, bridge, or LAG interface relations crossing the moved module's boundary"
+                ).format(count=outward_count + inward_count),
+                outward, inward,
+            ))
 
 
         # Power outlet to power port relations crossing the boundary
         # Power outlet to power port relations crossing the boundary
         moved_outlet_pks = {obj.pk for obj in self.components[PowerOutlet]}
         moved_outlet_pks = {obj.pk for obj in self.components[PowerOutlet]}
         moved_power_port_pks = {obj.pk for obj in self.components[PowerPort]}
         moved_power_port_pks = {obj.pk for obj in self.components[PowerPort]}
-        split_power = PowerOutlet.objects.filter(
+        split_outlets = PowerOutlet.objects.filter(
             pk__in=moved_outlet_pks, power_port__isnull=False
             pk__in=moved_outlet_pks, power_port__isnull=False
-        ).exclude(power_port_id__in=moved_power_port_pks).count()
-        split_power += PowerOutlet.objects.exclude(pk__in=moved_outlet_pks).filter(
+        ).exclude(power_port_id__in=moved_power_port_pks)
+        adopted_outlets = PowerOutlet.objects.exclude(pk__in=moved_outlet_pks).filter(
             power_port_id__in=moved_power_port_pks
             power_port_id__in=moved_power_port_pks
-        ).count()
+        )
+        split_power = split_outlets.count() + adopted_outlets.count()
         if split_power:
         if split_power:
-            blockers.append(_(
-                "{count} power outlet relations crossing the moved module's boundary"
-            ).format(count=split_power))
+            blockers.append(self._name_sample(
+                _(
+                    "{count} power outlet relations crossing the moved module's boundary"
+                ).format(count=split_power),
+                split_outlets, adopted_outlets,
+            ))
+
+        # Cooling outflow to cooling intake relations crossing the boundary. Only this direction is
+        # device-scoped (CoolingOutflow.clean() requires an intake on the same device); an intake's
+        # upstream CoolingOutflow is routinely supplied by another device, such as a CDU, and so is
+        # deliberately left alone.
+        moved_intake_pks = {obj.pk for obj in self.components[CoolingIntake]}
+        moved_outflow_pks = {obj.pk for obj in self.components[CoolingOutflow]}
+        split_outflows = CoolingOutflow.objects.filter(
+            pk__in=moved_outflow_pks, cooling_intake__isnull=False
+        ).exclude(cooling_intake_id__in=moved_intake_pks)
+        adopted_outflows = CoolingOutflow.objects.exclude(pk__in=moved_outflow_pks).filter(
+            cooling_intake_id__in=moved_intake_pks
+        )
+        split_cooling = split_outflows.count() + adopted_outflows.count()
+        if split_cooling:
+            blockers.append(self._name_sample(
+                _(
+                    "{count} cooling outflow relations crossing the moved module's boundary"
+                ).format(count=split_cooling),
+                split_outflows, adopted_outflows,
+            ))
 
 
         # Front/rear port mappings crossing the boundary
         # Front/rear port mappings crossing the boundary
         moved_front_port_pks = {obj.pk for obj in self.components[FrontPort]}
         moved_front_port_pks = {obj.pk for obj in self.components[FrontPort]}
         moved_rear_port_pks = {obj.pk for obj in self.components[RearPort]}
         moved_rear_port_pks = {obj.pk for obj in self.components[RearPort]}
-        split_mappings = PortMapping.objects.filter(
+        split_fronts = PortMapping.objects.filter(
             front_port_id__in=moved_front_port_pks
             front_port_id__in=moved_front_port_pks
-        ).exclude(rear_port_id__in=moved_rear_port_pks).count()
-        split_mappings += PortMapping.objects.filter(
+        ).exclude(rear_port_id__in=moved_rear_port_pks)
+        split_rears = PortMapping.objects.filter(
             rear_port_id__in=moved_rear_port_pks
             rear_port_id__in=moved_rear_port_pks
-        ).exclude(front_port_id__in=moved_front_port_pks).count()
+        ).exclude(front_port_id__in=moved_front_port_pks)
+        split_mappings = split_fronts.count() + split_rears.count()
         if split_mappings:
         if split_mappings:
-            blockers.append(_(
-                "{count} front/rear port mappings crossing the moved module's boundary"
-            ).format(count=split_mappings))
+            blockers.append(self._name_sample(
+                _(
+                    "{count} front/rear port mappings crossing the moved module's boundary"
+                ).format(count=split_mappings),
+                # PortMapping has no name of its own, so name the moved port on each side of the
+                # boundary: the front port when its rear port stays behind, and the rear port when
+                # its front port does. Naming the non-moved end instead would point the user at a
+                # component they will not find on the module they are moving.
+                FrontPort.objects.filter(pk__in=split_fronts.values('front_port_id')),
+                RearPort.objects.filter(pk__in=split_rears.values('rear_port_id')),
+            ))
 
 
         # Attached inventory items (blocked in v1)
         # Attached inventory items (blocked in v1)
-        item_count = 0
+        item_querysets = []
         for model, instances in self.components.items():
         for model, instances in self.components.items():
-            pks = [obj.pk for obj in instances]
-            if pks:
-                item_count += model.objects.filter(
-                    pk__in=pks, inventory_items__isnull=False
-                ).distinct().count()
+            if pks := [obj.pk for obj in instances]:
+                item_querysets.append(
+                    model.objects.filter(pk__in=pks, inventory_items__isnull=False).distinct()
+                )
         if bay_pks := [bay.pk for bay in self.moved_bays]:
         if bay_pks := [bay.pk for bay in self.moved_bays]:
-            item_count += ModuleBay.objects.filter(
-                pk__in=bay_pks, inventory_items__isnull=False
-            ).distinct().count()
-        if item_count:
-            blockers.append(_("{count} components with attached inventory items").format(count=item_count))
+            item_querysets.append(
+                ModuleBay.objects.filter(pk__in=bay_pks, inventory_items__isnull=False).distinct()
+            )
+        if item_count := sum(queryset.count() for queryset in item_querysets):
+            blockers.append(self._name_sample(
+                _("{count} components with attached inventory items").format(count=item_count),
+                *item_querysets,
+            ))
 
 
         if not blockers:
         if not blockers:
             return []
             return []
@@ -519,7 +601,9 @@ class ModuleMovePlan:
                 device_id=self.new_device_id, name__in=seen
                 device_id=self.new_device_id, name__in=seen
             ).exclude(pk__in=[move.instance.pk for move in moves])
             ).exclude(pk__in=[move.instance.pk for move in moves])
             if count := conflict_qs.count():
             if count := conflict_qs.count():
-                sample = ', '.join(conflict_qs.order_by('name').values_list('name', flat=True)[:5])
+                sample = ', '.join(
+                    conflict_qs.order_by('name').values_list('name', flat=True)[:self.SAMPLE_LIMIT]
+                )
                 errors.append(
                 errors.append(
                     _(
                     _(
                         "Moving this module would conflict with {count} existing {type} on device "
                         "Moving this module would conflict with {count} existing {type} on device "
@@ -694,7 +778,9 @@ class ModuleMovePlan:
             module.snapshot()
             module.snapshot()
             module.device_id = self.new_device_id
             module.device_id = self.new_device_id
             module.last_updated = self._now
             module.last_updated = self._now
-        self.module_model.objects.bulk_update(descendants, ['device', 'last_updated'], batch_size=BATCH_SIZE)
+        self.module_model.objects.bulk_update(
+            descendants, ['device', 'last_updated'], batch_size=settings.BULK_UPDATE_CHUNK_SIZE
+        )
         self._send_post_saves(self.module_model, descendants, ['device', 'last_updated'])
         self._send_post_saves(self.module_model, descendants, ['device', 'last_updated'])
 
 
     def _apply_bays(self):
     def _apply_bays(self):
@@ -744,7 +830,7 @@ class ModuleMovePlan:
         # Stage 1: parent-only, for the root's direct child bays being reparented.
         # Stage 1: parent-only, for the root's direct child bays being reparented.
         reparented = [bay for bay, changed in bay_changes if 'parent' in changed]
         reparented = [bay for bay, changed in bay_changes if 'parent' in changed]
         if reparented:
         if reparented:
-            ModuleBay.objects.bulk_update(reparented, ['parent'], batch_size=BATCH_SIZE)
+            ModuleBay.objects.bulk_update(reparented, ['parent'], batch_size=settings.BULK_UPDATE_CHUNK_SIZE)
 
 
         # Stage 2: renames, level-by-level top-down. Same-level bays are disjoint
         # Stage 2: renames, level-by-level top-down. Same-level bays are disjoint
         # subtrees, so per-level statements cannot overlap, and level N's AFTER-trigger
         # subtrees, so per-level statements cannot overlap, and level N's AFTER-trigger
@@ -768,11 +854,13 @@ class ModuleMovePlan:
         for level_index in sorted(renames_by_level):
         for level_index in sorted(renames_by_level):
             level_bays = renames_by_level[level_index]
             level_bays = renames_by_level[level_index]
             fields = sorted({field for _bay, bay_fields in level_bays for field in bay_fields})
             fields = sorted({field for _bay, bay_fields in level_bays for field in bay_fields})
-            ModuleBay.objects.bulk_update([bay for bay, _field in level_bays], fields, batch_size=BATCH_SIZE)
+            ModuleBay.objects.bulk_update(
+                [bay for bay, _field in level_bays], fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
+            )
 
 
         # Stage 3: one scalar statement for every changed bay; never parent/name here.
         # Stage 3: one scalar statement for every changed bay; never parent/name here.
         updated = [bay for bay, _ in bay_changes]
         updated = [bay for bay, _ in bay_changes]
-        ModuleBay.objects.bulk_update(updated, ['last_updated'], batch_size=BATCH_SIZE)
+        ModuleBay.objects.bulk_update(updated, ['last_updated'], batch_size=settings.BULK_UPDATE_CHUNK_SIZE)
 
 
         # Stage 4: sync in-memory ltree columns, then emit post_save per bay with the
         # Stage 4: sync in-memory ltree columns, then emit post_save per bay with the
         # union of its own changed fields (fields differ per bay, so one call each).
         # union of its own changed fields (fields differ per bay, so one call each).
@@ -816,7 +904,7 @@ class ModuleMovePlan:
                 fields.add('_name')
                 fields.add('_name')
             fields.add('last_updated')
             fields.add('last_updated')
             fields = sorted(fields)
             fields = sorted(fields)
-            model.objects.bulk_update(updated, fields, batch_size=BATCH_SIZE)
+            model.objects.bulk_update(updated, fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE)
             self._send_post_saves(model, updated, fields)
             self._send_post_saves(model, updated, fields)
 
 
     def _apply_port_mappings(self):
     def _apply_port_mappings(self):
@@ -834,22 +922,36 @@ class ModuleMovePlan:
                 device_id=self.new_device_id,
                 device_id=self.new_device_id,
             )
             )
 
 
+    @staticmethod
+    def device_counters_by_model(device_model):
+        """
+        Map each model counted by a device-scoped counter cache on Device to that counter's
+        field name. Derived from Device's own field declarations so that a modular component
+        model added to COMPONENT_TEMPLATE_ATTRS cannot silently skip counter recomputation -
+        a drift which raises nothing and only shows up as a wrong count on two devices.
+        """
+        return {
+            apps.get_model(field.to_model_name): field.name
+            for field in device_model._meta.get_fields()
+            if isinstance(field, CounterCacheField) and field.to_field_name == 'device'
+        }
+
+    def _moved_row_counts(self):
+        """
+        Moved row counts keyed by model, covering everything this plan relocates. ModuleBay is
+        tracked outside self.components (see _discover()), so it is added back here.
+        """
+        counts = {model: len(instances) for model, instances in self.components.items()}
+        counts[ModuleBay] = len(self.moved_bays)
+        return counts
+
     def _recompute_counters(self):
     def _recompute_counters(self):
         # bulk updates bypass the signal-driven counters; apply exact deltas for both devices
         # bulk updates bypass the signal-driven counters; apply exact deltas for both devices
         if not self.cross_device:
         if not self.cross_device:
             return
             return
-        counts = {
-            'console_port_count': len(self.components[ConsolePort]),
-            'console_server_port_count': len(self.components[ConsoleServerPort]),
-            'power_port_count': len(self.components[PowerPort]),
-            'power_outlet_count': len(self.components[PowerOutlet]),
-            'interface_count': len(self.components[Interface]),
-            'front_port_count': len(self.components[FrontPort]),
-            'rear_port_count': len(self.components[RearPort]),
-            'module_bay_count': len(self.moved_bays),
-        }
-        for counter, count in counts.items():
-            if count:
+        counts = self._moved_row_counts()
+        for model, counter in self.device_counters_by_model(self.device_model).items():
+            if count := counts.get(model, 0):
                 update_counter(self.device_model, self.old_device_id, counter, -count)
                 update_counter(self.device_model, self.old_device_id, counter, -count)
                 update_counter(self.device_model, self.new_device_id, counter, count)
                 update_counter(self.device_model, self.new_device_id, counter, count)
 
 

+ 39 - 0
netbox/dcim/tests/test_api.py

@@ -2749,6 +2749,45 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header)
         response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
+    def test_create_with_conflicting_cooling_component_fails(self):
+        """
+        A cooling component name collision must be reported as a validation error rather
+        than raising an IntegrityError from the replication insert. See netbox#15289.
+        """
+        self.add_permissions('dcim.add_module')
+        device = create_test_device('Cooling Conflict Device')
+        module_bay = ModuleBay.objects.create(device=device, name='Cooling Conflict Bay')
+        module_type = ModuleType.objects.create(
+            manufacturer=Manufacturer.objects.first(), model='Cooled API Type'
+        )
+        CoolingIntakeTemplate.objects.create(module_type=module_type, name='Intake 1')
+        CoolingIntake.objects.create(device=device, name='Intake 1')
+
+        response = self.client.post(reverse('dcim-api:module-list'), {
+            'device': device.pk,
+            'module_bay': module_bay.pk,
+            'module_type': module_type.pk,
+            'status': 'active',
+        }, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('Intake 1', str(response.data))
+
+    def test_patch_cross_device_move_blocked_by_split_cooling_relation(self):
+        self.add_permissions('dcim.change_module')
+        module = Module.objects.order_by('pk').first()
+        intake = CoolingIntake.objects.create(
+            device=module.device, module=module, name='Move Test Intake 1'
+        )
+        CoolingOutflow.objects.create(
+            device=module.device, name='Move Test Chassis Outflow', cooling_intake=intake
+        )
+        device_b = create_test_device('Module Move Device B')
+        bay_b = ModuleBay.objects.create(device=device_b, name='Module Move Bay B1')
+
+        url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
+        response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
 
 
 class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
 class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
     model = ConsolePort

+ 44 - 0
netbox/dcim/tests/test_forms.py

@@ -324,6 +324,50 @@ class ModuleFormTestCase(TestCase):
             self.assertFalse(form.is_valid())
             self.assertFalse(form.is_valid())
         self.assertIn('contains a cycle', str(form.errors))
         self.assertIn('contains a cycle', str(form.errors))
 
 
+    def test_module_form_reports_conflicting_cooling_component(self):
+        """
+        A cooling component name collision must surface as a form error rather than an
+        IntegrityError raised from the replication insert. See netbox#15289.
+        """
+        cooled_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Cooled Form Type'
+        )
+        CoolingIntakeTemplate.objects.create(module_type=cooled_type, name='Intake 1')
+        CoolingOutflowTemplate.objects.create(module_type=cooled_type, name='Outflow 1')
+        CoolingIntake.objects.create(device=self.device, name='Intake 1')
+        form = ModuleForm(
+            data={
+                'device': self.device.pk,
+                'module_bay': self.bay_b.pk,
+                'module_type': cooled_type.pk,
+                'status': 'active',
+                'replicate_components': True,
+            },
+        )
+        self.assertFalse(form.is_valid())
+        self.assertIn('Intake 1', str(form.errors))
+
+    def test_module_form_adopts_existing_cooling_component(self):
+        cooled_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Adoptable Cooled Type'
+        )
+        CoolingIntakeTemplate.objects.create(module_type=cooled_type, name='Intake 1')
+        intake = CoolingIntake.objects.create(device=self.device, name='Intake 1')
+        form = ModuleForm(
+            data={
+                'device': self.device.pk,
+                'module_bay': self.bay_b.pk,
+                'module_type': cooled_type.pk,
+                'status': 'active',
+                'replicate_components': True,
+                'adopt_components': True,
+            },
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        module = form.save()
+        intake.refresh_from_db()
+        self.assertEqual(intake.module, module)
+
 
 
 class VCPositionTokenFormTestCase(TestCase):
 class VCPositionTokenFormTestCase(TestCase):
 
 

+ 505 - 2
netbox/dcim/tests/test_module_moves.py

@@ -1,17 +1,30 @@
+import re
 import signal
 import signal
+import uuid
 from contextlib import contextmanager
 from contextlib import contextmanager
 from unittest.mock import patch
 from unittest.mock import patch
 
 
+from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import IntegrityError, OperationalError, connection, router, transaction
 from django.db import IntegrityError, OperationalError, connection, router, transaction
-from django.test import TestCase
+from django.db.models import QuerySet
+from django.test import RequestFactory, TestCase, override_settings
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 
 
 from circuits.models import Provider, ProviderNetwork, VirtualCircuit, VirtualCircuitTermination, VirtualCircuitType
 from circuits.models import Provider, ProviderNetwork, VirtualCircuit, VirtualCircuitTermination, VirtualCircuitType
+from core.models import ObjectChange
 from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices, ModuleStatusChoices, PortTypeChoices
 from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices, ModuleStatusChoices, PortTypeChoices
 from dcim.models import (
 from dcim.models import (
     Cable,
     Cable,
+    ConsolePortTemplate,
+    ConsoleServerPortTemplate,
+    CoolingIntake,
+    CoolingIntakeTemplate,
+    CoolingOutflow,
+    CoolingOutflowTemplate,
     Device,
     Device,
+    DeviceBay,
     DeviceRole,
     DeviceRole,
     DeviceType,
     DeviceType,
     FrontPort,
     FrontPort,
@@ -36,10 +49,13 @@ from dcim.models import (
     Site,
     Site,
     VirtualDeviceContext,
     VirtualDeviceContext,
 )
 )
-from dcim.models.module_moves import ModuleMovePlan
+from dcim.models.device_components import ModularComponentModel
+from dcim.models.module_moves import COMPONENT_TEMPLATE_ATTRS, ModuleMovePlan
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from ipam.choices import FHRPGroupProtocolChoices
 from ipam.choices import FHRPGroupProtocolChoices
 from ipam.models import VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress, VLANTranslationPolicy
 from ipam.models import VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress, VLANTranslationPolicy
+from netbox.context_managers import event_tracking
+from users.models import User
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from utilities.testing import create_test_device
 from utilities.testing import create_test_device
@@ -1228,6 +1244,119 @@ class ModuleCrossDeviceBlockerTestCase(TestCase):
         )
         )
         self._assert_move_allowed()
         self._assert_move_allowed()
 
 
+    #
+    # Blockers name the offending components, not just how many there are
+    #
+
+    def _blocked_message(self):
+        self.module.device = self.device_b
+        self.module.module_bay = self.bay_b
+        with self.assertRaises(ValidationError) as cm:
+            self.module.full_clean()
+        return str(cm.exception)
+
+    @staticmethod
+    def _samples(message):
+        """Every parenthesized "e.g." list in a blocker message, as a list of name lists."""
+        return [
+            [name.strip() for name in group.split(',')]
+            for group in re.findall(r'\(e\.g\. ([^)]*)\)', message)
+        ]
+
+    def test_cable_blocker_names_offending_component(self):
+        peer = Interface.objects.create(
+            device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        Cable(a_terminations=[self.interface], b_terminations=[peer]).save()
+        # A second moved interface with no cable must not be named
+        Interface.objects.create(
+            device=self.device_a, module=self.module, name='quiet0',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        message = self._blocked_message()
+        self.assertIn('eth0', message)
+        self.assertNotIn('quiet0', message)
+
+    def test_interface_state_blocker_names_offending_interface(self):
+        IPAddress.objects.create(address='192.0.2.1/24', assigned_object=self.interface)
+        self.assertEqual(self._samples(self._blocked_message()), [['eth0']])
+
+    def test_boundary_blocker_names_both_directions(self):
+        outsider = Interface.objects.create(
+            device=self.device_a, name='outsider0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        self.interface.bridge = outsider
+        self.interface.save()
+        lag = Interface.objects.create(
+            device=self.device_a, module=self.module, name='lag0', type=InterfaceTypeChoices.TYPE_LAG
+        )
+        Interface.objects.create(
+            device=self.device_a, name='member0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag
+        )
+        message = self._blocked_message()
+        self.assertIn('2 parent, bridge, or LAG interface relations', message)
+        self.assertEqual(self._samples(message), [['eth0', 'member0']])
+
+    def test_split_power_outlet_blocker_names_outlet(self):
+        power_port = PowerPort.objects.create(device=self.device_a, name='PP 1')
+        PowerOutlet.objects.create(
+            device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port
+        )
+        self.assertEqual(self._samples(self._blocked_message()), [['Outlet 1']])
+
+    def test_split_port_mapping_blocker_names_front_port(self):
+        front_port = FrontPort.objects.create(
+            device=self.device_a, module=self.module, name='Front 1', type=PortTypeChoices.TYPE_LC
+        )
+        rear_port = RearPort.objects.create(
+            device=self.device_a, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1
+        )
+        PortMapping.objects.create(
+            front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
+        )
+        self.assertEqual(self._samples(self._blocked_message()), [['Front 1']])
+
+    def test_split_port_mapping_blocker_names_moved_rear_port(self):
+        """
+        With the rear port moving and the front port staying behind, the sample must name the
+        rear port: a user told to look for the front port would not find it on this module.
+        """
+        rear_port = RearPort.objects.create(
+            device=self.device_a, module=self.module, name='Moved Rear 1',
+            type=PortTypeChoices.TYPE_LC, positions=1,
+        )
+        front_port = FrontPort.objects.create(
+            device=self.device_a, name='Chassis Front 1', type=PortTypeChoices.TYPE_LC
+        )
+        PortMapping.objects.create(
+            front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
+        )
+        message = self._blocked_message()
+        self.assertEqual(self._samples(message), [['Moved Rear 1']])
+        self.assertNotIn('Chassis Front 1', message)
+
+    def test_inventory_item_blocker_names_component(self):
+        InventoryItem.objects.create(device=self.device_a, name='Item 1', component=self.interface)
+        self.assertEqual(self._samples(self._blocked_message()), [['eth0']])
+
+    def test_blocker_sample_is_capped_but_count_is_complete(self):
+        peer = Interface.objects.create(
+            device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        Cable(a_terminations=[self.interface], b_terminations=[peer]).save()
+        for i in range(1, 8):
+            marked = Interface.objects.create(
+                device=self.device_a, module=self.module, name=f'eth{i}',
+                type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+            )
+            marked.mark_connected = True
+            marked.save()
+        message = self._blocked_message()
+        self.assertIn('8 cabled or connection-marked interfaces', message)
+        sample, = self._samples(message)
+        self.assertEqual(len(sample), ModuleMovePlan.SAMPLE_LIMIT)
+        self.assertEqual(sample, ['eth0', 'eth1', 'eth2', 'eth3', 'eth4'])
+
     def test_mac_address_is_allowed(self):
     def test_mac_address_is_allowed(self):
         mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=self.interface)
         mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=self.interface)
         self.interface.primary_mac_address = mac
         self.interface.primary_mac_address = mac
@@ -1431,6 +1560,53 @@ class ModuleCrossDeviceMoveTestCase(TestCase):
         three_children_queries = build_and_move(3)
         three_children_queries = build_and_move(3)
         self.assertEqual(one_child_queries, three_children_queries)
         self.assertEqual(one_child_queries, three_children_queries)
 
 
+    def test_bulk_updates_use_configured_chunk_size(self):
+        """
+        The move path must honour BULK_UPDATE_CHUNK_SIZE rather than a private constant, so
+        that an operator bounding rows-per-statement bounds this operation too.
+        """
+        original_bulk_update = QuerySet.bulk_update
+        batch_sizes = []
+
+        def recording_bulk_update(self, objs, fields, batch_size=None, **kwargs):
+            batch_sizes.append(batch_size)
+            return original_bulk_update(self, objs, fields, batch_size=batch_size, **kwargs)
+
+        with override_settings(BULK_UPDATE_CHUNK_SIZE=7):
+            with patch.object(QuerySet, 'bulk_update', recording_bulk_update):
+                self._move_to_device_b()
+
+        self.assertTrue(batch_sizes, 'the move issued no bulk_update calls')
+        self.assertEqual(set(batch_sizes), {7})
+
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=1)
+    def test_move_is_correct_when_updates_are_chunked(self):
+        """
+        A chunk size small enough to split every statement must not disturb the staged bay
+        writes, whose correctness depends on the ltree triggers settling per level.
+        """
+        sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
+        self._move_to_device_b()
+
+        self.line_card.refresh_from_db()
+        self.sfp_module.refresh_from_db()
+        self.assertEqual(self.line_card.device, self.device_b)
+        self.assertEqual(self.sfp_module.device, self.device_b)
+
+        moved_bay = ModuleBay.objects.get(pk=self.sfp_bay.pk)
+        dest_bay = ModuleBay.objects.get(pk=self.slot_2_b.pk)
+        self.assertEqual(moved_bay.name, 'SFP bay 2/1')
+        self.assertEqual(moved_bay.device, self.device_b)
+        self.assertTrue(str(moved_bay.path).startswith(f'{dest_bay.path}.'))
+
+        sfp_interface.refresh_from_db()
+        self.assertEqual(sfp_interface.name, 'SFP 2/1')
+        self.assertEqual(sfp_interface.device, self.device_b)
+        self.assertEqual(sfp_interface._site, self.site_b)
+        self.assertEqual(
+            self.line_card.interfaces.get().name, 'Ethernet2/1'
+        )
+
     def test_cross_device_move_refreshes_bay_sort_path(self):
     def test_cross_device_move_refreshes_bay_sort_path(self):
         """
         """
         The trigger-maintained sort_path of a moved nested bay reflects the
         The trigger-maintained sort_path of a moved nested bay reflects the
@@ -1445,3 +1621,330 @@ class ModuleCrossDeviceMoveTestCase(TestCase):
         self.assertNotEqual(moved_bay.sort_path, old_sort_path)
         self.assertNotEqual(moved_bay.sort_path, old_sort_path)
         self.assertTrue(str(moved_bay.sort_path).startswith(str(dest_bay.sort_path)))
         self.assertTrue(str(moved_bay.sort_path).startswith(str(dest_bay.sort_path)))
         self.assertIn('SFP bay 2/1', str(moved_bay.sort_path))
         self.assertIn('SFP bay 2/1', str(moved_bay.sort_path))
+
+
+class ModuleMoveComponentCoverageTestCase(TestCase):
+    """
+    Guard against a newly introduced modular component model being left out of the move
+    planner, which is how cooling intakes and outflows were initially missed.
+    """
+
+    def test_every_modular_component_model_is_planned(self):
+        # Scoped to dcim: a plugin may define its own ModularComponentModel subclass, which core
+        # cannot add to COMPONENT_TEMPLATE_ATTRS, so it must not fail this assertion.
+        core_models = {
+            model for model in apps.get_models()
+            if issubclass(model, ModularComponentModel) and model._meta.app_label == 'dcim'
+        }
+        # ModuleBay is planned separately (nested hierarchy, distinct uniqueness constraint).
+        planned = set(COMPONENT_TEMPLATE_ATTRS) | {ModuleBay}
+        self.assertEqual(
+            core_models - planned, set(),
+            'Modular component model(s) are not relocated by ModuleMovePlan. '
+            'Add them to COMPONENT_TEMPLATE_ATTRS.'
+        )
+
+    def test_planned_template_attrs_exist_on_module_type(self):
+        for model, template_attr in COMPONENT_TEMPLATE_ATTRS.items():
+            with self.subTest(model=model._meta.label):
+                self.assertTrue(
+                    hasattr(ModuleType, template_attr),
+                    f'ModuleType has no relation {template_attr!r}'
+                )
+
+    def test_device_counters_are_derived_for_every_planned_model(self):
+        """
+        Counter recomputation is derived from Device's own CounterCacheField declarations, so
+        adding a modular component model cannot silently skip it. Assert the derivation still
+        resolves a counter for each planned model, and does not reach beyond device-scoped ones.
+        """
+        counters = ModuleMovePlan.device_counters_by_model(Device)
+        planned = set(COMPONENT_TEMPLATE_ATTRS) | {ModuleBay}
+        self.assertEqual(
+            planned - set(counters), set(),
+            'A planned model has no device-scoped Device counter. If that is intended, this '
+            'assertion needs to record the exception explicitly.'
+        )
+        self.assertEqual(counters[CoolingIntake], 'cooling_intake_count')
+        self.assertEqual(counters[ModuleBay], 'module_bay_count')
+        # Models counted by Device but never moved must not gain a delta
+        self.assertNotIn(DeviceBay, planned)
+        self.assertNotIn(InventoryItem, planned)
+
+
+class ModuleMoveCounterTestCase(TestCase):
+    """
+    A cross-device move must adjust the Device counter of every modular component model the
+    planner relocates. Exercises all of them at once, so a broken counter derivation cannot
+    pass by covering only the component types other tests happen to create.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        role = DeviceRole.objects.create(name='Role 1', slug='role-1')
+        site = Site.objects.create(name='Site A', slug='site-a')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis')
+        ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1')
+        ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2')
+
+        # One template of every modular component type, so each planned model contributes a row
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Full Card')
+        ConsolePortTemplate.objects.create(module_type=cls.module_type, name='Console 1')
+        ConsoleServerPortTemplate.objects.create(module_type=cls.module_type, name='Console Server 1')
+        CoolingIntakeTemplate.objects.create(module_type=cls.module_type, name='Intake 1')
+        CoolingOutflowTemplate.objects.create(module_type=cls.module_type, name='Outflow 1')
+        InterfaceTemplate.objects.create(
+            module_type=cls.module_type, name='Ethernet 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        power_port = PowerPortTemplate.objects.create(module_type=cls.module_type, name='PP 1')
+        PowerOutletTemplate.objects.create(
+            module_type=cls.module_type, name='Outlet 1', power_port=power_port
+        )
+        front_port = FrontPortTemplate.objects.create(
+            module_type=cls.module_type, name='Front 1', type=PortTypeChoices.TYPE_LC
+        )
+        rear_port = RearPortTemplate.objects.create(
+            module_type=cls.module_type, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1
+        )
+        PortTemplateMapping.objects.create(
+            module_type=cls.module_type,
+            front_port=front_port, front_port_position=1,
+            rear_port=rear_port, rear_port_position=1,
+        )
+        ModuleBayTemplate.objects.create(module_type=cls.module_type, name='Sub bay 1')
+
+        cls.device_a = Device.objects.create(
+            name='Chassis A', device_type=device_type, role=role, site=site
+        )
+        cls.device_b = Device.objects.create(
+            name='Chassis B', device_type=device_type, role=role, site=site
+        )
+
+    def test_cross_device_move_adjusts_every_planned_counter(self):
+        module = Module.objects.create(
+            device=self.device_a, module_bay=self.device_a.modulebays.get(name='Slot 1'),
+            module_type=self.module_type,
+        )
+
+        # Fail loudly rather than vacuously if the fixture stops covering every planned model
+        rows = {model: model.objects.filter(module=module).count() for model in COMPONENT_TEMPLATE_ATTRS}
+        rows[ModuleBay] = ModuleBay.objects.filter(module=module).count()
+        self.assertEqual(
+            set(rows.values()), {1},
+            f'the fixture must create exactly one row per planned model, got {rows}'
+        )
+
+        counters = ModuleMovePlan.device_counters_by_model(Device)
+        planned_counters = sorted(counters[model] for model in rows)
+        self.device_a.refresh_from_db()
+        self.device_b.refresh_from_db()
+        before_a = {counter: getattr(self.device_a, counter) for counter in planned_counters}
+        before_b = {counter: getattr(self.device_b, counter) for counter in planned_counters}
+
+        module.device = self.device_b
+        module.module_bay = self.device_b.modulebays.get(name='Slot 2')
+        module.full_clean()
+        module.save()
+
+        self.device_a.refresh_from_db()
+        self.device_b.refresh_from_db()
+        for counter in planned_counters:
+            with self.subTest(counter=counter):
+                self.assertEqual(
+                    getattr(self.device_a, counter), before_a[counter] - 1,
+                    f'{counter} was not decremented on the source device'
+                )
+                self.assertEqual(
+                    getattr(self.device_b, counter), before_b[counter] + 1,
+                    f'{counter} was not incremented on the destination device'
+                )
+
+
+class ModuleMoveCoolingTestCase(TestCase):
+    """
+    Cooling intakes and outflows are modular components and must be relocated, renamed, and
+    counted like any other. See netbox#15289.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        role = DeviceRole.objects.create(name='Role 1', slug='role-1')
+        cls.site_a = Site.objects.create(name='Site A', slug='site-a')
+        cls.site_b = Site.objects.create(name='Site B', slug='site-b')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis')
+        ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1', position='1')
+        ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2', position='2')
+
+        cls.card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Cooled Card')
+        CoolingIntakeTemplate.objects.create(module_type=cls.card_type, name='Intake {module}/1')
+        CoolingOutflowTemplate.objects.create(module_type=cls.card_type, name='Outflow {module}/1')
+        ModuleBayTemplate.objects.create(
+            module_type=cls.card_type, name='Sub bay {module}/1', position='{module}/1'
+        )
+        cls.sub_type = ModuleType.objects.create(manufacturer=manufacturer, model='Cooled Sub')
+        CoolingIntakeTemplate.objects.create(module_type=cls.sub_type, name='Sub intake {module}')
+
+        cls.device_a = Device.objects.create(
+            name='Chassis A', device_type=device_type, role=role, site=cls.site_a
+        )
+        cls.device_b = Device.objects.create(
+            name='Chassis B', device_type=device_type, role=role, site=cls.site_b
+        )
+        cls.slot_1_a = cls.device_a.modulebays.get(name='Slot 1')
+        cls.slot_2_a = cls.device_a.modulebays.get(name='Slot 2')
+        cls.slot_2_b = cls.device_b.modulebays.get(name='Slot 2')
+
+    def setUp(self):
+        super().setUp()
+        self.card = Module.objects.create(
+            device=self.device_a, module_bay=self.slot_1_a, module_type=self.card_type
+        )
+        self.intake = self.card.coolingintakes.get()
+        self.outflow = self.card.coolingoutflows.get()
+
+    def _move_to_device_b(self):
+        self.card.device = self.device_b
+        self.card.module_bay = self.slot_2_b
+        self.card.full_clean()
+        self.card.save()
+
+    def test_same_device_move_renames_cooling_components(self):
+        self.card.module_bay = self.slot_2_a
+        self.card.full_clean()
+        self.card.save()
+        self.intake.refresh_from_db()
+        self.outflow.refresh_from_db()
+        self.assertEqual(self.intake.name, 'Intake 2/1')
+        self.assertEqual(self.outflow.name, 'Outflow 2/1')
+        self.assertEqual(self.intake.device, self.device_a)
+
+    def test_cross_device_move_relocates_cooling_components(self):
+        self._move_to_device_b()
+        self.intake.refresh_from_db()
+        self.outflow.refresh_from_db()
+        for component in (self.intake, self.outflow):
+            self.assertEqual(component.device, self.device_b)
+            self.assertEqual(component._site, self.site_b)
+            self.assertEqual(component._location, self.device_b.location)
+            self.assertEqual(component._rack, self.device_b.rack)
+        self.assertEqual(self.intake.name, 'Intake 2/1')
+        self.assertEqual(self.outflow.name, 'Outflow 2/1')
+
+    def test_cross_device_move_relocates_nested_cooling_components(self):
+        sub_bay = self.card.modulebays.get()
+        sub_module = Module.objects.create(
+            device=self.device_a, module_bay=sub_bay, module_type=self.sub_type
+        )
+        sub_intake = sub_module.coolingintakes.get()
+        self.assertEqual(sub_intake.name, 'Sub intake 1/1')
+        self._move_to_device_b()
+        sub_intake.refresh_from_db()
+        self.assertEqual(sub_intake.device, self.device_b)
+        self.assertEqual(sub_intake.name, 'Sub intake 2/1')
+
+    def test_cross_device_move_recomputes_cooling_counters(self):
+        self.device_a.refresh_from_db()
+        self.assertEqual(self.device_a.cooling_intake_count, 1)
+        self.assertEqual(self.device_a.cooling_outflow_count, 1)
+        self._move_to_device_b()
+        self.device_a.refresh_from_db()
+        self.device_b.refresh_from_db()
+        self.assertEqual(self.device_a.cooling_intake_count, 0)
+        self.assertEqual(self.device_a.cooling_outflow_count, 0)
+        self.assertEqual(self.device_b.cooling_intake_count, 1)
+        self.assertEqual(self.device_b.cooling_outflow_count, 1)
+
+    def test_reinstall_into_vacated_bay_after_move(self):
+        """
+        The vacated bay must be reusable: a stale cooling name left on the source device
+        would collide with the replacement module's replicated components.
+        """
+        self._move_to_device_b()
+        replacement = Module(
+            device=self.device_a, module_bay=self.slot_1_a, module_type=self.card_type
+        )
+        replacement.full_clean()
+        replacement.save()
+        self.assertEqual(replacement.coolingintakes.get().name, 'Intake 1/1')
+
+    def test_cooling_name_conflict_at_destination_is_rejected(self):
+        CoolingIntake.objects.create(device=self.device_b, name='Intake 2/1')
+        self.card.device = self.device_b
+        self.card.module_bay = self.slot_2_b
+        with self.assertRaises(ValidationError) as cm:
+            self.card.full_clean()
+        self.assertIn('would conflict with', str(cm.exception))
+
+    def test_split_cooling_outflow_relation_blocks(self):
+        """An outflow's upstream intake must stay on the same device, so it cannot be split."""
+        device_intake = CoolingIntake.objects.create(device=self.device_a, name='Chassis Intake')
+        self.outflow.cooling_intake = device_intake
+        self.outflow.save()
+        self.card.device = self.device_b
+        self.card.module_bay = self.slot_2_b
+        with self.assertRaises(ValidationError) as cm:
+            self.card.full_clean()
+        self.assertIn('cooling outflow relations crossing', str(cm.exception))
+        self.assertIn('Outflow 1/1', str(cm.exception))
+
+    def test_inward_cooling_outflow_relation_blocks(self):
+        device_outflow = CoolingOutflow.objects.create(device=self.device_a, name='Chassis Outflow')
+        device_outflow.cooling_intake = self.intake
+        device_outflow.save()
+        self.card.device = self.device_b
+        self.card.module_bay = self.slot_2_b
+        with self.assertRaises(ValidationError) as cm:
+            self.card.full_clean()
+        self.assertIn('cooling outflow relations crossing', str(cm.exception))
+        self.assertIn('Chassis Outflow', str(cm.exception))
+
+    def test_intra_module_cooling_pair_is_allowed(self):
+        self.outflow.cooling_intake = self.intake
+        self.outflow.save()
+        self._move_to_device_b()
+        self.outflow.refresh_from_db()
+        self.assertEqual(self.outflow.device, self.device_b)
+        self.assertEqual(self.outflow.cooling_intake, self.intake)
+
+    def test_upstream_outflow_on_another_device_is_allowed(self):
+        """
+        CoolingIntake.cooling_outflow is not device-scoped: an intake is routinely supplied
+        by an outflow on another device, such as a CDU. It must not block a move.
+        """
+        cdu_outflow = CoolingOutflow.objects.create(device=self.device_a, name='CDU Outflow')
+        self.intake.cooling_outflow = cdu_outflow
+        self.intake.save()
+        self._move_to_device_b()
+        self.intake.refresh_from_db()
+        self.assertEqual(self.intake.device, self.device_b)
+        self.assertEqual(self.intake.cooling_outflow, cdu_outflow)
+
+    def test_cooling_components_are_changelogged(self):
+        with event_tracking(self._make_request()):
+            self.card.snapshot()
+            self._move_to_device_b()
+        intake_type = ContentType.objects.get_for_model(CoolingIntake)
+        change = ObjectChange.objects.get(
+            changed_object_type=intake_type, changed_object_id=self.intake.pk
+        )
+        self.assertEqual(change.prechange_data['name'], 'Intake 1/1')
+        self.assertEqual(change.postchange_data['name'], 'Intake 2/1')
+        self.assertEqual(change.postchange_data['device'], self.device_b.pk)
+
+    def _make_request(self):
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='cooling-mover')
+        return request
+
+    def test_attached_inventory_item_on_cooling_component_blocks(self):
+        InventoryItem.objects.create(
+            device=self.device_a, name='Coolant Sensor', component=self.intake
+        )
+        self.card.device = self.device_b
+        self.card.module_bay = self.slot_2_b
+        with self.assertRaises(ValidationError) as cm:
+            self.card.full_clean()
+        self.assertIn('attached inventory items', str(cm.exception))