فهرست منبع

Closes #15289: Allow moving Modules between Bays and Devices (#22704)

Fixes #15289
Martin Hauser 1 ماه پیش
والد
کامیت
cfbbceea4d

+ 10 - 0
docs/models/dcim/module.md

@@ -4,6 +4,16 @@ A module is a field-replaceable hardware component installed within a device whi
 
 Similar to devices, modules are instantiated from [module types](./moduletype.md), and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a [module bay](./modulebay.md) on a [device](./device.md), and each module bay may have only one module installed in it.
 
+## Moving Modules
+
+An installed module can be moved to a different module bay after creation. The destination bay must be enabled and unoccupied. Moving a module relocates its entire subtree: the components installed by the module, the module bays belonging to it, and any child modules installed within those bays.
+
+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.
+
+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.
+
 ## Fields
 
 ### Device

+ 21 - 1
netbox/dcim/api/serializers_/devices.py

@@ -206,6 +206,23 @@ class ModuleSerializer(PrimaryModelSerializer):
         # construct a Module instance for full_clean(); restore them afterwards.
         replicate_components = data.pop('replicate_components', True)
         adopt_components = data.pop('adopt_components', False)
+
+        if self.instance is not None:
+            # Derive device from module_bay so full_clean() validates a consistent pair.
+            if 'module_bay' in data and 'device' not in data:
+                data['device'] = data['module_bay'].device
+            move_requested = (
+                ('module_bay' in data and data['module_bay'].pk != self.instance.module_bay_id) or
+                ('device' in data and data['device'].pk != self.instance.device_id)
+            )
+            if move_requested and 'module_type' in data and data['module_type'].pk != self.instance.module_type_id:
+                raise serializers.ValidationError({
+                    'module_type': _(
+                        "Changing a module's type while moving it is not supported. Change the module "
+                        "type and move the module as separate operations."
+                    )
+                })
+
         data = super().validate(data)
 
         # For updates these fields are not meaningful; omit them from validated_data so that
@@ -229,7 +246,10 @@ class ModuleSerializer(PrimaryModelSerializer):
         if not all([device, module_type, module_bay]):
             return data
 
-        positions = get_module_bay_positions(module_bay)
+        try:
+            positions = get_module_bay_positions(module_bay)
+        except ValueError as e:
+            raise serializers.ValidationError({'module_bay': str(e)}) from e
 
         for templates_attr, component_attr in [
             ('consoleporttemplates', 'consoleports'),

+ 4 - 1
netbox/dcim/forms/common.py

@@ -143,7 +143,10 @@ class ModuleCommonForm(forms.Form):
             self.instance._disable_replication = True
             return
 
-        positions = get_module_bay_positions(module_bay)
+        try:
+            positions = get_module_bay_positions(module_bay)
+        except ValueError as e:
+            raise forms.ValidationError(str(e))
 
         for templates, component_attribute in [
                 ("consoleporttemplates", "consoleports"),

+ 0 - 1
netbox/dcim/forms/model_forms.py

@@ -982,7 +982,6 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
         super().__init__(*args, **kwargs)
 
         if self.instance.pk:
-            self.fields['device'].disabled = True
             self.fields['replicate_components'].initial = False
             self.fields['replicate_components'].disabled = True
             self.fields['adopt_components'].initial = False

+ 6 - 1
netbox/dcim/models/device_component_templates.py

@@ -12,6 +12,7 @@ from dcim.models.mixins import InterfaceValidationMixin
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
 from netbox.models.ltree import LtreeManager, LtreeModel
+from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.tracking import TrackingModelMixin
@@ -195,7 +196,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
         if not has_module and not has_vc:
             return value
         if has_module and module:
-            positions = get_module_bay_positions(module.module_bay)
+            # Reached only from Module._save_new(); AbortRequest is what the view/viewset catches.
+            try:
+                positions = get_module_bay_positions(module.module_bay)
+            except ValueError as e:
+                raise AbortRequest(str(e)) from e
             value = resolve_module_placeholder(value, positions)
         if has_vc:
             resolved_device = (module.device if module else None) or device

+ 880 - 0
netbox/dcim/models/module_moves.py

@@ -0,0 +1,880 @@
+from dataclasses import dataclass
+
+from django.core.exceptions import ValidationError
+from django.db import router
+from django.db.models import Q
+from django.db.models.signals import post_save
+from django.utils import timezone
+from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy
+
+from dcim.constants import MODULE_TOKEN
+from dcim.utils import (
+    get_module_bay_positions,
+    get_module_bay_raw_positions,
+    resolve_module_placeholder,
+    resolve_position_chain,
+)
+from utilities.counters import update_counter
+from utilities.exceptions import AbortRequest
+
+from .device_components import (
+    ConsolePort,
+    ConsoleServerPort,
+    FrontPort,
+    Interface,
+    ModuleBay,
+    PortMapping,
+    PowerOutlet,
+    PowerPort,
+    RearPort,
+)
+
+__all__ = (
+    'ComponentMove',
+    'ModuleMovePlan',
+)
+
+BATCH_SIZE = 1000
+
+# Modular component models relocated during a move, mapped to the ModuleType template
+# accessor used for conservative template-derived renaming. ModuleBay is handled
+# separately (nested hierarchy, distinct uniqueness constraint).
+COMPONENT_TEMPLATE_ATTRS = {
+    ConsolePort: 'consoleporttemplates',
+    ConsoleServerPort: 'consoleserverporttemplates',
+    FrontPort: 'frontporttemplates',
+    Interface: 'interfacetemplates',
+    PowerOutlet: 'poweroutlettemplates',
+    PowerPort: 'powerporttemplates',
+    RearPort: 'rearporttemplates',
+}
+
+MODULEBAY_TEMPLATE_ATTR = 'modulebaytemplates'
+
+
+@dataclass
+class ComponentMove:
+    """
+    The planned final state of a single component affected by a module move. Unchanged
+    values remain equal to the instance's current values.
+    """
+    instance: object
+    target_name: str
+    target_label: str
+    target_position: str = None   # ModuleBay only
+    target_parent_id: int = None  # ModuleBay only; set for the root module's direct child bays
+
+
+class ModuleMovePlan:
+    """
+    Plans and applies the relocation of an installed module (including its nested module
+    subtree) to a different module bay and/or device. Build via from_module(), then call
+    lock(), validate(), and (after the root Module row has been saved) apply_after_root_save().
+    """
+
+    def __init__(self, old_module, new_module):
+        self.old_module = old_module
+        self.new_module = new_module
+        self.module_model = type(old_module)
+        self.device_model = self.module_model._meta.get_field('device').related_model
+        self.old_device_id = old_module.device_id
+        self.new_device_id = new_module.device_id
+        self.new_device = new_module.device
+        self.new_bay = new_module.module_bay
+        self.cross_device = old_module.device_id != new_module.device_id
+
+        self.modules_by_level = []   # [[Module]]; level 0 is [old_module]
+        # Seeded with the root module's own pk (always known without a query) so that
+        # lock()'s first (pre-discovery) and second (post-discovery) membership snapshots
+        # compare equal when the root module truly has no descendants, bays, or
+        # components, keeping the common case to a single discover+lock pass.
+        self.module_pks = {old_module.pk}
+        self.moved_bays = []         # all ModuleBays owned by moved modules
+        self.components = {}         # {model: [instances]} for COMPONENT_TEMPLATE_ATTRS models
+        self.component_moves = {model: [] for model in COMPONENT_TEMPLATE_ATTRS}
+        self.bay_moves = []          # [ComponentMove] for moved ModuleBays
+        self._target_resolution_failures = []  # display strings; see _record_target_failure()
+        self._template_cache = {}    # {(module_type_id, template_attr): [templates]} per planning pass
+        self._planned = False        # set once discovery + rename planning have run at least once
+        self._now = None
+
+    @classmethod
+    def from_module(cls, *, old_module, new_module):
+        """
+        Build a plan for the given move. Discovery and rename planning are NOT run here;
+        they run lazily (see _ensure_planned()) on the first call to validate(), or
+        eagerly inside lock() for the locked save path. A caller that goes on to call
+        lock() (Module._save_existing()) would otherwise pay for an unlocked discovery
+        pass that lock() immediately re-does under row locks - pure waste.
+        """
+        return cls(old_module, new_module)
+
+    def _ensure_planned(self):
+        """
+        Run discovery and rename planning if they have not already run for this
+        instance. lock() always (re-)discovers and (re-)plans itself under row locks, so
+        this is a no-op after lock() - it only does work for the unlocked clean() path,
+        where validate() is called directly against a freshly constructed plan.
+        """
+        if not self._planned:
+            self._discover()
+            self._plan_renames()
+            self._planned = True
+
+    def _discover(self):
+        """
+        Collect the moved subtree by module ownership: the root module, all ModuleBays
+        owned by moved modules (level by level), the modules installed in those bays,
+        and all non-bay components owned by any moved module.
+
+        Re-entrant: resets its accumulators first so a re-run (see lock()) reflects only
+        the current database state, not whatever a prior pass appended.
+        """
+        self.modules_by_level = []
+        self.moved_bays = []
+        self.components = {}
+
+        self.modules_by_level = [[self.old_module]]
+        visited_pks = {self.old_module.pk}
+        frontier = [self.old_module.pk]
+        while frontier:
+            level_bays = list(ModuleBay.objects.filter(module_id__in=frontier))
+            self.moved_bays.extend(level_bays)
+            child_modules = list(
+                self.module_model.objects.select_related('module_type').filter(
+                    module_bay_id__in=[bay.pk for bay in level_bays]
+                )
+            )
+            # A revisited module pk means a cycle (creatable via .update(), bypassing clean()).
+            for module in child_modules:
+                if module.pk in visited_pks:
+                    raise ValueError(_("Module bay hierarchy contains a cycle."))
+                visited_pks.add(module.pk)
+            frontier = [module.pk for module in child_modules]
+            if child_modules:
+                self.modules_by_level.append(child_modules)
+
+        self.module_pks = {module.pk for level in self.modules_by_level for module in level}
+        for model in COMPONENT_TEMPLATE_ATTRS:
+            self.components[model] = list(model.objects.filter(module_id__in=self.module_pks))
+
+    def _plan_renames(self):
+        """
+        Compute the planned final name/label/position for every moved component, top-down
+        so that a child module's new position context reflects its containing bay's
+        planned position. A component is renamed only when exactly one template of the
+        owning module's current type resolves to its current name in the old context.
+
+        Re-entrant: resets its accumulators first so a re-run reflects only the current
+        self.components/self.moved_bays, not whatever a prior pass appended.
+        """
+        self.component_moves = {model: [] for model in COMPONENT_TEMPLATE_ATTRS}
+        self.bay_moves = []
+        self._target_resolution_failures = []
+        self._template_cache = {}
+
+        # A target bay inside the moved subtree is rejected by validate(); do not walk its chain
+        if self.new_bay.pk in {bay.pk for bay in self.moved_bays}:
+            return
+
+        old_chains = {self.old_module.pk: get_module_bay_positions(self.old_module.module_bay)}
+        new_raw_chains = {self.old_module.pk: get_module_bay_raw_positions(self.new_bay)}
+
+        components_by_module = {model: {} for model in COMPONENT_TEMPLATE_ATTRS}
+        for model, instances in self.components.items():
+            for obj in instances:
+                components_by_module[model].setdefault(obj.module_id, []).append(obj)
+        bays_by_module = {}
+        for bay in self.moved_bays:
+            bays_by_module.setdefault(bay.module_id, []).append(bay)
+        installed_module_by_bay = {
+            module.module_bay_id: module
+            for level in self.modules_by_level[1:]
+            for module in level
+        }
+
+        for level in self.modules_by_level:
+            for module in level:
+                old_positions = old_chains[module.pk]
+                new_positions = resolve_position_chain(new_raw_chains[module.pk])
+
+                for model, template_attr in COMPONENT_TEMPLATE_ATTRS.items():
+                    templates_by_old_name = self._index_templates(
+                        self._cached_templates(module.module_type, template_attr), old_positions
+                    )
+                    for component in components_by_module[model].get(module.pk, []):
+                        self.component_moves[model].append(self._plan_component(
+                            component, templates_by_old_name, old_positions, new_positions
+                        ))
+
+                bay_templates_by_old_name = self._index_templates(
+                    self._cached_templates(module.module_type, MODULEBAY_TEMPLATE_ATTR), old_positions
+                )
+                for bay in bays_by_module.get(module.pk, []):
+                    move = self._plan_component(
+                        bay, bay_templates_by_old_name, old_positions, new_positions, include_position=True
+                    )
+                    if bay.module_id == self.old_module.pk:
+                        move.target_parent_id = self.new_bay.pk
+                    self.bay_moves.append(move)
+
+                    # Track planned chains raw and resolve on use: the fold inherits an
+                    # ancestor's {module} token from the planned position below it,
+                    # exactly as a fresh get_module_bay_positions() walk will once the
+                    # planned positions are stored, so planner and walker cannot diverge.
+                    if (child := installed_module_by_bay.get(bay.pk)) is not None:
+                        old_chains[child.pk] = get_module_bay_positions(bay)
+                        new_raw_chains[child.pk] = new_raw_chains[module.pk] + [move.target_position or '']
+
+    def _cached_templates(self, module_type, template_attr):
+        """
+        Return the given template queryset for module_type as a list, fetched once per
+        (module_type, template_attr) pair per planning pass regardless of how many moved
+        modules share that module_type.
+        """
+        key = (module_type.pk, template_attr)
+        if key not in self._template_cache:
+            self._template_cache[key] = list(getattr(module_type, template_attr).all())
+        return self._template_cache[key]
+
+    def _index_templates(self, templates, old_positions):
+        """
+        Map each template's old-context resolved name to the templates producing it. A
+        name is a usable rename hint only when exactly one template produces it.
+        """
+        index = {}
+        for template in templates:
+            try:
+                resolved = self._resolve(template, template.name, old_positions, self.old_module.device)
+            except ValueError:
+                continue
+            index.setdefault(resolved, []).append(template)
+        return index
+
+    def _plan_component(self, component, templates_by_old_name, old_positions, new_positions,
+                        include_position=False):
+        move = ComponentMove(instance=component, target_name=component.name, target_label=component.label)
+        if include_position:
+            move.target_position = component.position
+
+        matches = templates_by_old_name.get(component.name, ())
+        if len(matches) != 1:
+            return move
+        template = matches[0]
+
+        try:
+            move.target_name = self._resolve(template, template.name, new_positions, self.new_device)
+        except ValueError:
+            self._record_target_failure(component, 'name')
+            return move
+
+        try:
+            old_label = self._resolve(template, template.label, old_positions, self.old_module.device)
+        except ValueError:
+            old_label = None
+        if old_label is not None and component.label == old_label:
+            try:
+                move.target_label = self._resolve(template, template.label, new_positions, self.new_device)
+            except ValueError:
+                self._record_target_failure(component, 'label')
+
+        if include_position:
+            try:
+                old_position = self._resolve(
+                    template, template.position, old_positions, self.old_module.device
+                )
+            except ValueError:
+                old_position = None
+            if old_position is not None and component.position == old_position:
+                try:
+                    move.target_position = self._resolve(
+                        template, template.position, new_positions, self.new_device
+                    )
+                except ValueError:
+                    self._record_target_failure(component, 'position')
+
+        return move
+
+    @staticmethod
+    def _resolve(template, value, positions, device):
+        """
+        Resolve {module} and {vc_position} tokens in a template value against an explicit
+        position chain and device. Raises ValueError on a token-count mismatch.
+        """
+        if MODULE_TOKEN in value:
+            value = resolve_module_placeholder(value, positions)
+        return type(template)._resolve_vc_position(value, device)
+
+    def lock(self):
+        """
+        Acquire row locks in deterministic order, then re-discover: FK inserts take KEY
+        SHARE on their referenced rows, so membership is stable only once every owning
+        row is locked. Loop until a re-discovery pass finds no new members, then refresh
+        the target rows and recompute the planned changes from the locked state.
+        """
+        while True:
+            self._lock_current_set()
+            locked_pks = self._membership_pks()
+            self._discover()
+            if self._membership_pks() == locked_pks:
+                break
+        self._refresh_target_state()
+        self._plan_renames()
+        self._planned = True
+
+    def _membership_pks(self):
+        return (
+            frozenset(self.module_pks),
+            frozenset(bay.pk for bay in self.moved_bays),
+            frozenset((model._meta.label, obj.pk) for model, objs in self.components.items() for obj in objs),
+        )
+
+    def _refresh_target_state(self):
+        # A concurrently deleted target bay is reported by validate(), not raised here
+        if (bay := ModuleBay.objects.filter(pk=self.new_bay.pk).first()) is not None:
+            self.new_bay = bay
+        self.new_device.refresh_from_db()
+
+    def _lock_current_set(self):
+        """
+        Acquire row locks in a deterministic order: devices, module bays (source
+        containing bay, target bay, moved bays), descendant modules, then moved
+        components per model. The root Module row is locked by the caller.
+        """
+        device_pks = sorted({self.old_device_id, self.new_device_id})
+        locked_devices = list(
+            self.device_model.objects.select_for_update().filter(pk__in=device_pks).order_by('pk')
+        )
+        if len(locked_devices) != len(device_pks):
+            raise AbortRequest(_("Device was deleted before the move could be saved."))
+        bay_pks = sorted({
+            self.old_module.module_bay_id, self.new_bay.pk, *(bay.pk for bay in self.moved_bays)
+        })
+        list(ModuleBay.objects.select_for_update().filter(pk__in=bay_pks).order_by('pk'))
+        descendant_pks = sorted(self.module_pks - {self.old_module.pk})
+        if descendant_pks:
+            list(self.module_model.objects.select_for_update().filter(pk__in=descendant_pks).order_by('pk'))
+        for model in sorted(self.components, key=lambda model: model._meta.label):
+            pks = sorted(obj.pk for obj in self.components[model])
+            if pks:
+                list(model.objects.select_for_update().filter(pk__in=pks).order_by('pk'))
+
+    # Interface relations carrying topology or device-scoped configuration state which
+    # block a cross-device move
+    INTERFACE_BLOCKERS = (
+        (gettext_lazy('IP addresses assigned'), Q(ip_addresses__isnull=False)),
+        (gettext_lazy('FHRP group assignments'), Q(fhrp_group_assignments__isnull=False)),
+        (gettext_lazy('tunnel terminations'), Q(tunnel_terminations__isnull=False)),
+        (gettext_lazy('L2VPN terminations'), Q(l2vpn_terminations__isnull=False)),
+        (gettext_lazy('virtual circuit terminations'), Q(virtual_circuit_termination__isnull=False)),
+        (gettext_lazy('wireless links'), Q(wireless_link__isnull=False)),
+        (gettext_lazy('wireless LAN assignments'), Q(wireless_lans__isnull=False)),
+        (gettext_lazy('an untagged VLAN'), Q(untagged_vlan__isnull=False)),
+        (gettext_lazy('tagged VLANs'), Q(tagged_vlans__isnull=False)),
+        (gettext_lazy('a Q-in-Q service VLAN'), Q(qinq_svlan__isnull=False)),
+        (gettext_lazy('a VLAN translation policy'), Q(vlan_translation_policy__isnull=False)),
+        (gettext_lazy('VDC assignments'), Q(vdcs__isnull=False)),
+        (gettext_lazy('a VRF assignment'), Q(vrf__isnull=False)),
+    )
+
+    def validate(self):
+        """
+        Validate the move against current database state. Raises ValidationError with
+        all failures collected. Called unlocked from Module.clean() for UX and again
+        under row locks from Module.save(). The locked pass is authoritative for the
+        state its row locks serialize (the moved rows and FK-backed relations to
+        them). GenericForeignKey-backed relations (inventory items, IP addresses,
+        FHRP, tunnel, and L2VPN terminations) carry no database-level reference to
+        the moved rows, so a concurrent insert can still land alongside the move
+        after this check has passed; enforcing those invariants atomically is a
+        database-level follow-up.
+        """
+        self._ensure_planned()
+        errors = []
+        self._validate_target_bay(errors)
+        if self.cross_device:
+            errors.extend(self._check_cross_device_blockers())
+        errors.extend(self._check_name_conflicts())
+        errors.extend(self._check_length_violations())
+        errors.extend(self._check_target_resolution_failures())
+        if errors:
+            raise ValidationError(errors)
+
+    def _check_cross_device_blockers(self):
+        """
+        Reject a cross-device move when any moved component carries topology or
+        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
+        direction. Inventory items attached to a moved component also block (v1).
+        """
+        blockers = []
+        moved_interface_pks = {obj.pk for obj in self.components[Interface]}
+
+        # Cabled or connection-marked components
+        for model, instances in self.components.items():
+            pks = [obj.pk for obj in instances]
+            if not pks:
+                continue
+            count = model.objects.filter(pk__in=pks).filter(
+                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
+                ))
+
+        # Interface topology/configuration state
+        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))
+
+        # Parent/bridge/LAG relations crossing the moved-set boundary (either direction)
+        outward = Interface.objects.filter(pk__in=moved_interface_pks).filter(
+            Q(parent__isnull=False) & ~Q(parent_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)
+        ).count()
+        inward = Interface.objects.exclude(pk__in=moved_interface_pks).filter(
+            Q(parent_id__in=moved_interface_pks) |
+            Q(bridge_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))
+
+        # Power outlet to power port relations crossing the boundary
+        moved_outlet_pks = {obj.pk for obj in self.components[PowerOutlet]}
+        moved_power_port_pks = {obj.pk for obj in self.components[PowerPort]}
+        split_power = PowerOutlet.objects.filter(
+            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(
+            power_port_id__in=moved_power_port_pks
+        ).count()
+        if split_power:
+            blockers.append(_(
+                "{count} power outlet relations crossing the moved module's boundary"
+            ).format(count=split_power))
+
+        # Front/rear port mappings crossing the boundary
+        moved_front_port_pks = {obj.pk for obj in self.components[FrontPort]}
+        moved_rear_port_pks = {obj.pk for obj in self.components[RearPort]}
+        split_mappings = PortMapping.objects.filter(
+            front_port_id__in=moved_front_port_pks
+        ).exclude(rear_port_id__in=moved_rear_port_pks).count()
+        split_mappings += PortMapping.objects.filter(
+            rear_port_id__in=moved_rear_port_pks
+        ).exclude(front_port_id__in=moved_front_port_pks).count()
+        if split_mappings:
+            blockers.append(_(
+                "{count} front/rear port mappings crossing the moved module's boundary"
+            ).format(count=split_mappings))
+
+        # Attached inventory items (blocked in v1)
+        item_count = 0
+        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 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))
+
+        if not blockers:
+            return []
+        return [
+            _(
+                "This module cannot be moved to a different device because the moved components have "
+                "active related objects: {blockers}."
+            ).format(blockers='; '.join(str(blocker) for blocker in blockers))
+        ]
+
+    def _check_name_conflicts(self):
+        errors = []
+        for model, moves in self.component_moves.items():
+            if not moves:
+                continue
+            seen = set()
+            for move in moves:
+                if move.target_name in seen:
+                    errors.append(
+                        _("Moving this module would create more than one {type} named {name}.").format(
+                            type=model._meta.verbose_name, name=move.target_name
+                        )
+                    )
+                seen.add(move.target_name)
+            conflict_qs = model.objects.filter(
+                device_id=self.new_device_id, name__in=seen
+            ).exclude(pk__in=[move.instance.pk for move in moves])
+            if count := conflict_qs.count():
+                sample = ', '.join(conflict_qs.order_by('name').values_list('name', flat=True)[:5])
+                errors.append(
+                    _(
+                        "Moving this module would conflict with {count} existing {type} on device "
+                        "{device} (e.g. {sample})."
+                    ).format(
+                        count=count, type=model._meta.verbose_name_plural,
+                        device=self.new_device, sample=sample
+                    )
+                )
+            if not self.cross_device:
+                current_names = {move.instance.name for move in moves}
+                for move in moves:
+                    if move.target_name != move.instance.name and move.target_name in current_names:
+                        errors.append(
+                            _(
+                                "Moving this module would rename {old_name} to {new_name}, which is the "
+                                "current name of another moved {type}. Rename the affected components "
+                                "manually before moving."
+                            ).format(
+                                old_name=move.instance.name,
+                                new_name=move.target_name,
+                                type=model._meta.verbose_name,
+                            )
+                        )
+        # ModuleBay names are unique per (device, module, name); moved bays keep their
+        # module assignment, so conflicts are only possible within the moved set
+        seen_bays = set()
+        for move in self.bay_moves:
+            key = (move.instance.module_id, move.target_name)
+            if key in seen_bays:
+                errors.append(
+                    _(
+                        "Moving this module would create more than one module bay named {name} "
+                        "within the same module."
+                    ).format(name=move.target_name)
+                )
+            seen_bays.add(key)
+        if not self.cross_device:
+            current_bay_keys = {(move.instance.module_id, move.instance.name) for move in self.bay_moves}
+            for move in self.bay_moves:
+                if move.target_name != move.instance.name and (
+                    (move.instance.module_id, move.target_name) in current_bay_keys
+                ):
+                    errors.append(
+                        _(
+                            "Moving this module would rename module bay {old_name} to {new_name}, which is "
+                            "the current name of another moved module bay in the same module."
+                        ).format(old_name=move.instance.name, new_name=move.target_name)
+                    )
+        return errors
+
+    def _check_length_violations(self):
+        """
+        Reject a move whose planned rename would exceed the destination field's
+        max_length, rather than deferring to a mid-apply DataError from bulk_update().
+        Limits are read from model meta so a future field-length change stays correct
+        without editing this method.
+        """
+        offenders = []
+        for model, moves in self.component_moves.items():
+            name_limit = model._meta.get_field('name').max_length
+            label_limit = model._meta.get_field('label').max_length
+            for move in moves:
+                offenders.extend(self._length_offenders(move, name=name_limit, label=label_limit))
+        name_limit = ModuleBay._meta.get_field('name').max_length
+        label_limit = ModuleBay._meta.get_field('label').max_length
+        position_limit = ModuleBay._meta.get_field('position').max_length
+        for move in self.bay_moves:
+            offenders.extend(
+                self._length_offenders(move, name=name_limit, label=label_limit, position=position_limit)
+            )
+        if not offenders:
+            return []
+        return [
+            _("Moving this module would exceed the maximum field length for the following: {offenders}.").format(
+                offenders='; '.join(offenders)
+            )
+        ]
+
+    def _length_offenders(self, move, **limits):
+        """
+        Return one display string per (field, value) pair on move whose length exceeds
+        the given limit. limits maps a field name ('name', 'label', and 'position' for
+        module bays) to the destination model's max_length for that field.
+        """
+        values = {'name': move.target_name, 'label': move.target_label, 'position': move.target_position or ''}
+        offenders = []
+        for field, limit in limits.items():
+            value = values[field]
+            if len(value) > limit:
+                offenders.append(
+                    _("{component}: new {field} {value} ({length} characters) exceeds the "
+                      "{limit}-character limit").format(
+                        component=move.instance, field=field, value=self._truncate_for_display(value),
+                        length=len(value), limit=limit,
+                    )
+                )
+        return offenders
+
+    @staticmethod
+    def _truncate_for_display(value, limit=40):
+        if len(value) <= limit:
+            return value
+        return f'{value[:limit]}...'
+
+    def _record_target_failure(self, component, field):
+        """
+        Record a component whose source value matched a template that cannot be
+        resolved for the destination; reported collectively by validate().
+        """
+        self._target_resolution_failures.append(
+            _("{component}: the matched template's {field} cannot be resolved for the destination "
+              "bay hierarchy").format(component=component, field=field)
+        )
+
+    def _check_target_resolution_failures(self):
+        if not self._target_resolution_failures:
+            return []
+        return [
+            _(
+                "Moving this module would require template-derived values that cannot be resolved for "
+                "the destination bay hierarchy: {failures}. Choose a destination at a compatible "
+                "nesting depth or rename the affected components manually before moving."
+            ).format(failures='; '.join(self._target_resolution_failures))
+        ]
+
+    def _validate_target_bay(self, errors):
+        bay = ModuleBay.objects.filter(pk=self.new_bay.pk).first()
+        if bay is None:
+            errors.append(_("The target module bay no longer exists."))
+            return
+        if bay.device_id != self.new_device_id:
+            errors.append(
+                _("Module bay {module_bay} does not belong to device {device}.").format(
+                    module_bay=bay, device=self.new_device
+                )
+            )
+        if not bay.enabled:
+            errors.append(_("Cannot install a module in a disabled module bay."))
+        if occupant := self.module_model.objects.filter(
+            module_bay_id=bay.pk
+        ).exclude(pk=self.old_module.pk).first():
+            errors.append(
+                _("Module bay {module_bay} is already occupied by module {module}.").format(
+                    module_bay=bay, module=occupant
+                )
+            )
+        if bay.pk in {moved_bay.pk for moved_bay in self.moved_bays}:
+            errors.append(_("A module bay cannot belong to a module installed within it."))
+
+    def apply_after_root_save(self):
+        """
+        Apply the planned updates after the root Module row has been saved: descendant
+        modules, then module bays (parent re-pointing; ltree triggers recompute
+        path/sort_path), then components, port mappings, and device counters, with
+        manual post_save emission for changelog/search side effects.
+        """
+        self._now = timezone.now()
+        self._apply_descendant_modules()
+        self._apply_bays()
+        self._apply_components()
+        self._apply_port_mappings()
+        self._recompute_counters()
+
+    def _apply_descendant_modules(self):
+        if not self.cross_device:
+            return
+        descendants = [module for level in self.modules_by_level[1:] for module in level]
+        if not descendants:
+            return
+        for module in descendants:
+            module.snapshot()
+            module.device_id = self.new_device_id
+            module.last_updated = self._now
+        self.module_model.objects.bulk_update(descendants, ['device', 'last_updated'], batch_size=BATCH_SIZE)
+        self._send_post_saves(self.module_model, descendants, ['device', 'last_updated'])
+
+    def _apply_bays(self):
+        """
+        Persist planned bay changes in four stages so that ltree hierarchy columns
+        (parent) and naming columns (name/position/label) never share a bulk_update
+        statement across overlapping subtrees; see utilities/ltree.py for the trigger
+        behavior this must respect (BEFORE on parent_id/name; AFTER cascade on the same).
+        A cross-device move's device/_site/_location/_rack fields are written in the same
+        per-row statement as any rename below, so a bay's (device, name) pair changes as
+        one atomic write and is never transiently mismatched against either device.
+        """
+        bay_changes = []  # [(bay, changed_fields)]
+        for move in self.bay_moves:
+            bay = move.instance
+            changed = []
+            if move.target_parent_id is not None and bay.parent_id != move.target_parent_id:
+                changed.append('parent')
+            if bay.name != move.target_name:
+                changed.append('name')
+            if bay.label != move.target_label:
+                changed.append('label')
+            if move.target_position is not None and bay.position != move.target_position:
+                changed.append('position')
+            if self.cross_device:
+                changed.extend(['device', '_site', '_location', '_rack'])
+            if not changed:
+                continue
+            bay.snapshot()
+            if self.cross_device:
+                bay.device_id = self.new_device_id
+                bay._site = self.new_device.site
+                bay._location = self.new_device.location
+                bay._rack = self.new_device.rack
+            if 'parent' in changed:
+                bay.parent_id = move.target_parent_id
+            bay.name = move.target_name
+            bay.label = move.target_label
+            if move.target_position is not None:
+                bay.position = move.target_position
+            bay.last_updated = self._now
+            bay_changes.append((bay, changed))
+
+        if not bay_changes:
+            return
+
+        # 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]
+        if reparented:
+            ModuleBay.objects.bulk_update(reparented, ['parent'], batch_size=BATCH_SIZE)
+
+        # 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
+        # cascade settles descendant sort_paths before level N+1's statement runs.
+        # Cross-device device/_site/_location/_rack fields ride along in the same statement.
+        level_by_module_pk = {
+            module.pk: level_index
+            for level_index, level in enumerate(self.modules_by_level)
+            for module in level
+        }
+        renames_by_level = {}
+        for bay, changed in bay_changes:
+            level_fields = [
+                field for field in ('name', 'position', 'label', 'device', '_site', '_location', '_rack')
+                if field in changed
+            ]
+            if not level_fields:
+                continue
+            level_index = level_by_module_pk[bay.module_id]
+            renames_by_level.setdefault(level_index, []).append((bay, level_fields))
+        for level_index in sorted(renames_by_level):
+            level_bays = renames_by_level[level_index]
+            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)
+
+        # Stage 3: one scalar statement for every changed bay; never parent/name here.
+        updated = [bay for bay, _ in bay_changes]
+        ModuleBay.objects.bulk_update(updated, ['last_updated'], batch_size=BATCH_SIZE)
+
+        # 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).
+        self._refresh_ltree_columns(updated)
+        for bay, changed in bay_changes:
+            self._send_post_saves(ModuleBay, [bay], sorted({*changed, 'last_updated'}))
+
+    def _apply_components(self):
+        for model, moves in self.component_moves.items():
+            updated = []
+            update_fields = set()
+            for move in moves:
+                component = move.instance
+                changed = []
+                if component.name != move.target_name:
+                    changed.append('name')
+                if component.label != move.target_label:
+                    changed.append('label')
+                if self.cross_device:
+                    changed.extend(['device', '_site', '_location', '_rack'])
+                if not changed:
+                    continue
+                component.snapshot()
+                if self.cross_device:
+                    component.device_id = self.new_device_id
+                    component._site = self.new_device.site
+                    component._location = self.new_device.location
+                    component._rack = self.new_device.rack
+                component.name = move.target_name
+                component.label = move.target_label
+                component.last_updated = self._now
+                updated.append(component)
+                update_fields.update(changed)
+            if not updated:
+                continue
+            fields = set(update_fields)
+            if model is Interface and 'name' in update_fields:
+                name_field = Interface._meta.get_field('_name')
+                for component in updated:
+                    name_field.pre_save(component, False)
+                fields.add('_name')
+            fields.add('last_updated')
+            fields = sorted(fields)
+            model.objects.bulk_update(updated, fields, batch_size=BATCH_SIZE)
+            self._send_post_saves(model, updated, fields)
+
+    def _apply_port_mappings(self):
+        # Private model, no changelog or last_updated field; mirrors PortMapping.save()'s device derivation.
+        if not self.cross_device:
+            return
+        moved_front_port_pks = [obj.pk for obj in self.components[FrontPort]]
+        moved_rear_port_pks = [obj.pk for obj in self.components[RearPort]]
+        if moved_front_port_pks and moved_rear_port_pks:
+            PortMapping.objects.filter(
+                front_port_id__in=moved_front_port_pks,
+                rear_port_id__in=moved_rear_port_pks,
+            ).update(device_id=self.new_device_id)
+
+    def _recompute_counters(self):
+        # bulk updates bypass the signal-driven counters; apply exact deltas for both devices
+        if not self.cross_device:
+            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:
+                update_counter(self.device_model, self.old_device_id, counter, -count)
+                update_counter(self.device_model, self.new_device_id, counter, count)
+
+    def _refresh_ltree_columns(self, bays):
+        """
+        bulk_update fires the DB triggers that rewrite path/sort_path, but the in-memory
+        instances keep stale values which would leak into changelog snapshots.
+        """
+        refreshed = {
+            row['pk']: row
+            for row in ModuleBay.objects.filter(pk__in=[bay.pk for bay in bays]).values(
+                'pk', 'path', 'sort_path'
+            )
+        }
+        for bay in bays:
+            bay.path = refreshed[bay.pk]['path']
+            bay.sort_path = refreshed[bay.pk]['sort_path']
+
+    @staticmethod
+    def _send_post_saves(model, instances, update_fields):
+        for instance in instances:
+            # Clear tracked counter state so the incremental counter receiver no-ops;
+            # counters are recomputed explicitly for cross-device moves.
+            instance.tracker.clear()
+            post_save.send(
+                sender=model,
+                instance=instance,
+                created=False,
+                raw=False,
+                using=router.db_for_write(model),
+                update_fields=update_fields,
+            )

+ 107 - 15
netbox/dcim/models/modules.py

@@ -3,7 +3,7 @@ from collections.abc import Iterable, Mapping
 import jsonschema
 import yaml
 from django.core.exceptions import ValidationError
-from django.db import models
+from django.db import OperationalError, models, router, transaction
 from django.db.models.signals import post_save
 from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
@@ -14,12 +14,14 @@ from extras.models import CustomField
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
+from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, CounterCacheField
 from utilities.jsonschema import validate_schema
 from utilities.string import title
 from utilities.tracking import TrackingModelMixin
 
 from .device_components import *
+from .module_moves import ModuleMovePlan
 
 __all__ = (
     'Module',
@@ -430,6 +432,32 @@ class Module(TrackingModelMixin, PrimaryModel):
                     'module_bay': _("Cannot install a module in a disabled module bay.")
                 })
 
+        # Prevent installation into an occupied module bay
+        if hasattr(self, 'module_bay') and self.module_bay_id and (
+            occupant := Module.objects.filter(module_bay_id=self.module_bay_id).exclude(pk=self.pk).first()
+        ):
+            raise ValidationError({
+                'module_bay': _(
+                    "Module bay {module_bay} is already occupied by module {module}."
+                ).format(module_bay=self.module_bay, module=occupant)
+            })
+
+        # Validate a requested move (device and/or module bay change) of an existing module
+        if not self._state.adding and hasattr(self, 'module_bay') and self.module_bay_id and self.device_id:
+            old = Module.objects.filter(pk=self.pk).first()
+            if old and (old.device_id != self.device_id or old.module_bay_id != self.module_bay_id):
+                if old.module_type_id != self.module_type_id:
+                    raise ValidationError({
+                        'module_type': _(
+                            "Changing a module's type while moving it is not supported. Change the module "
+                            "type and move the module as separate operations."
+                        )
+                    })
+                try:
+                    ModuleMovePlan.from_module(old_module=old, new_module=self).validate()
+                except ValueError as e:
+                    raise ValidationError({'module_bay': str(e)}) from e
+
         # Check for recursion
         module = self
         module_bays = []
@@ -444,27 +472,27 @@ class Module(TrackingModelMixin, PrimaryModel):
             module = module_module_bay.module if module_module_bay else None
 
     def save(self, *args, **kwargs):
-        is_new = self.pk is None
-        old_module_bay_id = None
+        if self.pk is None:
+            self._save_new(*args, **kwargs)
+            return
+
+        update_fields = kwargs.get('update_fields')
+        placement_fields = {'device', 'device_id', 'module_bay', 'module_bay_id'}
+        if update_fields is not None and placement_fields.isdisjoint(update_fields):
+            # Placement columns cannot be written by this save, so no move can occur.
+            super().save(*args, **kwargs)
+            return
 
-        if not is_new:
-            old_module_bay_id = Module.objects.filter(pk=self.pk).values_list(
-                'module_bay_id', flat=True
-            ).first()
+        self._save_existing(*args, **kwargs)
 
+    def _save_new(self, *args, **kwargs):
         super().save(*args, **kwargs)
 
-        if old_module_bay_id is not None and old_module_bay_id != self.module_bay_id:
-            for child_bay in self.modulebays.select_related('module__module_bay'):
-                child_bay.snapshot()
-                child_bay.save()
-
         adopt_components = getattr(self, '_adopt_components', False)
         disable_replication = getattr(self, '_disable_replication', False)
 
-        # We skip adding components if the module is being edited or
-        # both replication and component adoption is disabled
-        if not is_new or (disable_replication and not adopt_components):
+        # We skip adding components if both replication and component adoption is disabled
+        if disable_replication and not adopt_components:
             return
 
         # Iterate all component types
@@ -565,3 +593,67 @@ class Module(TrackingModelMixin, PrimaryModel):
 
         # Interface bridges have to be set after interface instantiation
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
+
+    def _save_existing(self, *args, **kwargs):
+        try:
+            with transaction.atomic(using=router.db_for_write(Module)):
+                # Root row locks first (matches API ETag path); all routing below decides from this locked read
+                locked_old = Module.objects.select_for_update().only(
+                    'device', 'module_bay', 'module_type'
+                ).filter(pk=self.pk).first()
+                if locked_old is None:
+                    # A new pk, or a row concurrently deleted; create instead.
+                    self._save_new(*args, **kwargs)
+                    return
+
+                delta_fields = []
+                if locked_old.device_id != self.device_id:
+                    delta_fields.append('device')
+                if locked_old.module_bay_id != self.module_bay_id:
+                    delta_fields.append('module_bay')
+
+                if not delta_fields:
+                    super().save(*args, **kwargs)
+                    return
+
+                update_fields = kwargs.get('update_fields')
+                if update_fields is not None:
+                    field_attnames = {'device': 'device_id', 'module_bay': 'module_bay_id'}
+                    listed = {
+                        field for field in delta_fields
+                        if field in update_fields or field_attnames[field] in update_fields
+                    }
+                    if not listed:
+                        # None of the changed placement fields are part of this write, so no move happens.
+                        super().save(*args, **kwargs)
+                        return
+                    if listed != set(delta_fields):
+                        raise AbortRequest(_(
+                            "A module move must include every changed placement field in update_fields: "
+                            "'device' (or 'device_id') and/or 'module_bay' (or 'module_bay_id')."
+                        ))
+
+                if locked_old.module_type_id != self.module_type_id:
+                    raise AbortRequest(_(
+                        "Changing a module's type while moving it is not supported. Change the module type and "
+                        "move the module as separate operations."
+                    ))
+
+                try:
+                    plan = ModuleMovePlan.from_module(old_module=locked_old, new_module=self)
+                    plan.lock()
+                except ValueError as e:
+                    raise AbortRequest(str(e)) from e
+                try:
+                    plan.validate()
+                except ValidationError as e:
+                    raise AbortRequest(' '.join(e.messages)) from e
+
+                super().save(*args, **kwargs)
+                plan.apply_after_root_save()
+        except OperationalError as e:
+            if getattr(e.__cause__, 'sqlstate', None) == '40P01':
+                raise AbortRequest(_(
+                    "This module or its components are being modified by another request. Please try again."
+                )) from e
+            raise

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

@@ -2343,6 +2343,14 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+        cls.update_data = {
+            'device': device.pk,
+            'module_bay': module_bays[3].pk,
+            'module_type': module_types[0].pk,
+            'status': 'active',
+            'serial': 'ABC123',
+        }
+
     def test_is_bay_compatible_flag(self):
         """
         is_bay_compatible should be True when no bay types are set, and False when the
@@ -2653,6 +2661,72 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(len(response.data['results']), 1)
 
+    def test_patch_module_bay_derives_device(self):
+        self.add_permissions('dcim.change_module')
+        module = Module.objects.order_by('pk').first()
+        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_200_OK)
+        module.refresh_from_db()
+        self.assertEqual(module.device, device_b)
+        self.assertEqual(module.module_bay, bay_b)
+
+    def test_patch_device_and_module_bay_mismatch_fails(self):
+        self.add_permissions('dcim.change_module')
+        module = Module.objects.order_by('pk').first()
+        device_b = create_test_device('Module Move Device B')
+        same_device_bay = ModuleBay.objects.create(device=module.device, name='Module Move Bay A9')
+
+        url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
+        response = self.client.patch(
+            url, {'device': device_b.pk, 'module_bay': same_device_bay.pk}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+    def test_patch_module_type_with_move_fails(self):
+        self.add_permissions('dcim.change_module')
+        module = Module.objects.order_by('pk').first()
+        empty_bay = ModuleBay.objects.filter(
+            device=module.device, installed_module__isnull=True
+        ).first()
+        other_type = ModuleType.objects.exclude(pk=module.module_type_id).first()
+
+        url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
+        response = self.client.patch(
+            url, {'module_bay': empty_bay.pk, 'module_type': other_type.pk}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('module_type', response.data)
+
+    def test_patch_occupied_bay_fails(self):
+        self.add_permissions('dcim.change_module')
+        module_1, module_2 = Module.objects.order_by('pk')[:2]
+
+        url = reverse('dcim-api:module-detail', kwargs={'pk': module_1.pk})
+        response = self.client.patch(
+            url, {'module_bay': module_2.module_bay_id}, format='json', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('module_bay', response.data)
+
+    def test_patch_cross_device_move_blocked_by_ip_address(self):
+        self.add_permissions('dcim.change_module')
+        module = Module.objects.order_by('pk').first()
+        interface = Interface.objects.create(
+            device=module.device, module=module, name='Move Test Interface 1',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        IPAddress.objects.create(address='192.0.2.10/32', assigned_object=interface)
+        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):
     model = ConsolePort

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

@@ -16,6 +16,7 @@ from dcim.choices import (
 )
 from dcim.forms import *
 from dcim.models import *
+from dcim.tests.test_module_moves import fail_after
 from ipam.models import ASN, RIR, VLAN
 from utilities.exceptions import AbortRequest
 from utilities.forms.rendering import M2MAddRemoveFields
@@ -228,6 +229,102 @@ class ModuleTypeFormTestCase(TestCase):
             self.assertEqual(module_type.attribute_data, {'media': ['copper', 'qsfp28']})
 
 
+class ModuleFormTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = create_test_device('Module Form Device A')
+        cls.device_b = create_test_device('Module Form Device B')
+        cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
+        cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
+        cls.bay_c = ModuleBay.objects.create(device=cls.device_b, name='Bay C')
+        manufacturer = Manufacturer.objects.create(
+            name='Module Form Manufacturer', slug='module-form-manufacturer'
+        )
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Form Type')
+        cls.module = Module.objects.create(
+            device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
+        )
+
+    def test_module_device_is_editable_on_edit(self):
+        form = ModuleForm(instance=self.module)
+        self.assertFalse(form.fields['device'].disabled)
+        self.assertTrue(form.fields['replicate_components'].disabled)
+        self.assertTrue(form.fields['adopt_components'].disabled)
+
+    def test_module_form_moves_module_to_empty_bay(self):
+        form = ModuleForm(
+            data={
+                'device': self.device.pk,
+                'module_bay': self.bay_b.pk,
+                'module_type': self.module_type.pk,
+                'status': 'active',
+            },
+            instance=self.module,
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        form.save()
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.module_bay, self.bay_b)
+
+    def test_module_form_rejects_occupied_bay(self):
+        Module.objects.create(device=self.device, module_bay=self.bay_b, module_type=self.module_type)
+        form = ModuleForm(
+            data={
+                'device': self.device.pk,
+                'module_bay': self.bay_b.pk,
+                'module_type': self.module_type.pk,
+                'status': 'active',
+            },
+            instance=self.module,
+        )
+        self.assertFalse(form.is_valid())
+        self.assertIn('module_bay', form.errors)
+
+    def test_module_form_moves_module_to_different_device(self):
+        interface = Interface.objects.create(
+            device=self.device, module=self.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleForm(
+            data={
+                'device': self.device_b.pk,
+                'module_bay': self.bay_c.pk,
+                'module_type': self.module_type.pk,
+                'status': 'active',
+            },
+            instance=self.module,
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        form.save()
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.device, self.device_b)
+        self.assertEqual(self.module.module_bay, self.bay_c)
+        interface.refresh_from_db()
+        self.assertEqual(interface.device, self.device_b)
+
+    def test_module_create_into_cyclic_hierarchy_is_rejected(self):
+        # CREATE into a cyclic hierarchy (bypassing clean() via .update()) must be a form error.
+        other_module = Module.objects.create(
+            device=self.device, module_bay=self.bay_b, module_type=self.module_type
+        )
+        child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1')
+        child_bay_2 = ModuleBay.objects.create(device=self.device, module=other_module, name='Child Bay 2')
+        Module.objects.filter(pk=self.module.pk).update(module_bay=child_bay_2)
+        Module.objects.filter(pk=other_module.pk).update(module_bay=child_bay_1)
+        form = ModuleForm(
+            data={
+                'device': self.device.pk,
+                'module_bay': child_bay_1.pk,
+                'module_type': self.module_type.pk,
+                'status': 'active',
+                'replicate_components': True,
+            },
+        )
+        with fail_after(15):
+            self.assertFalse(form.is_valid())
+        self.assertIn('contains a cycle', str(form.errors))
+
+
 class VCPositionTokenFormTestCase(TestCase):
 
     @classmethod

+ 4 - 3
netbox/dcim/tests/test_models.py

@@ -1014,9 +1014,10 @@ class ModuleBayTestCase(TestCase):
             module_bay_1.clean()
             module_bay_1.save()
 
-        # Confirm error if Module recurses
-        with self.assertRaises(ValidationError):
-            module_1.module_bay = module_bay_3
+        # Confirm error if Module recurses (empty target bay, so the occupied-bay check cannot mask it)
+        module_bay_4 = ModuleBay.objects.create(device=module_1.device, name='Module Bay 4', module=module_3)
+        with self.assertRaisesMessage(ValidationError, 'cannot belong to a module installed within it'):
+            module_1.module_bay = module_bay_4
             module_1.clean()
             module_1.save()
 

+ 1447 - 0
netbox/dcim/tests/test_module_moves.py

@@ -0,0 +1,1447 @@
+import signal
+from contextlib import contextmanager
+from unittest.mock import patch
+
+from django.core.exceptions import ValidationError
+from django.db import IntegrityError, OperationalError, connection, router, transaction
+from django.test import TestCase
+from django.test.utils import CaptureQueriesContext
+
+from circuits.models import Provider, ProviderNetwork, VirtualCircuit, VirtualCircuitTermination, VirtualCircuitType
+from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices, ModuleStatusChoices, PortTypeChoices
+from dcim.models import (
+    Cable,
+    Device,
+    DeviceRole,
+    DeviceType,
+    FrontPort,
+    FrontPortTemplate,
+    Interface,
+    InterfaceTemplate,
+    InventoryItem,
+    MACAddress,
+    Manufacturer,
+    Module,
+    ModuleBay,
+    ModuleBayTemplate,
+    ModuleType,
+    PortMapping,
+    PortTemplateMapping,
+    PowerOutlet,
+    PowerOutletTemplate,
+    PowerPort,
+    PowerPortTemplate,
+    RearPort,
+    RearPortTemplate,
+    Site,
+    VirtualDeviceContext,
+)
+from dcim.models.module_moves import ModuleMovePlan
+from dcim.utils import get_module_bay_positions, resolve_module_placeholder
+from ipam.choices import FHRPGroupProtocolChoices
+from ipam.models import VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress, VLANTranslationPolicy
+from utilities.exceptions import AbortRequest
+from utilities.ordering import naturalize_interface
+from utilities.testing import create_test_device
+from vpn.choices import L2VPNTypeChoices, TunnelEncapsulationChoices
+from vpn.models import L2VPN, L2VPNTermination, Tunnel, TunnelTermination
+from wireless.models import WirelessLAN, WirelessLink
+
+
+@contextmanager
+def fail_after(seconds):
+    """
+    Fail the enclosed block if it runs longer than the given number of seconds. Backstop
+    for the cycle-guard tests: a regressed hang fails one test fast instead of stalling the run.
+    """
+    def on_alarm(signum, frame):
+        raise AssertionError(f'Operation did not complete within {seconds} seconds.')
+
+    previous_handler = signal.signal(signal.SIGALRM, on_alarm)
+    signal.alarm(seconds)
+    try:
+        yield
+    finally:
+        signal.alarm(0)
+        signal.signal(signal.SIGALRM, previous_handler)
+
+
+class ModuleMoveValidationTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device_a = create_test_device('Device A')
+        cls.device_b = create_test_device('Device B')
+        cls.bays_a = [
+            ModuleBay.objects.create(device=cls.device_a, name=f'Bay A{i}') for i in range(1, 4)
+        ]
+        cls.bays_b = [
+            ModuleBay.objects.create(device=cls.device_b, name=f'Bay B{i}') for i in range(1, 3)
+        ]
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
+        cls.other_module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type N')
+        cls.module = Module.objects.create(
+            device=cls.device_a, module_bay=cls.bays_a[0], module_type=cls.module_type
+        )
+
+    def test_create_in_occupied_bay_fails(self):
+        module = Module(device=self.device_a, module_bay=self.bays_a[0], module_type=self.module_type)
+        with self.assertRaises(ValidationError) as cm:
+            module.full_clean()
+        self.assertIn('module_bay', cm.exception.message_dict)
+
+    def test_move_to_occupied_bay_fails(self):
+        occupant = Module.objects.create(
+            device=self.device_a, module_bay=self.bays_a[1], module_type=self.module_type
+        )
+        self.module.module_bay = self.bays_a[1]
+        with self.assertRaises(ValidationError) as cm:
+            self.module.full_clean()
+        self.assertIn('module_bay', cm.exception.message_dict)
+        self.assertIn(str(occupant), str(cm.exception.message_dict['module_bay']))
+
+    def test_move_to_own_bay_passes(self):
+        self.module.serial = 'ABC123'
+        self.module.full_clean()
+
+    def test_move_to_other_device_bay_without_device_change_fails(self):
+        # Existing device/bay consistency rule must keep rejecting a half-specified move
+        self.module.module_bay = self.bays_b[0]
+        with self.assertRaises(ValidationError):
+            self.module.full_clean()
+
+    def test_move_with_module_type_change_fails(self):
+        self.module.module_bay = self.bays_a[1]
+        self.module.module_type = self.other_module_type
+        with self.assertRaises(ValidationError) as cm:
+            self.module.full_clean()
+        self.assertIn('module_type', cm.exception.message_dict)
+
+    def test_module_type_change_without_move_passes(self):
+        self.module.module_type = self.other_module_type
+        self.module.full_clean()
+        self.module.save()
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.module_type, self.other_module_type)
+
+
+class ModuleSameDeviceMoveTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = create_test_device('Device A')
+        cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A', position='A')
+        cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B', position='B')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
+        cls.module = Module.objects.create(
+            device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
+        )
+        cls.interface = Interface.objects.create(
+            device=cls.device, module=cls.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        cls.child_bay = ModuleBay.objects.create(
+            device=cls.device, module=cls.module, name='Child Bay 1'
+        )
+
+    def test_move_to_empty_bay_updates_module_bay(self):
+        self.module.module_bay = self.bay_b
+        self.module.full_clean()
+        self.module.save()
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.module_bay, self.bay_b)
+
+    def test_move_reparents_direct_child_bays(self):
+        self.module.module_bay = self.bay_b
+        self.module.save()
+        self.child_bay.refresh_from_db()
+        self.assertEqual(self.child_bay.parent_id, self.bay_b.pk)
+        self.assertTrue(str(self.child_bay.path).startswith(f'{self.bay_b.path}.'))
+
+    def test_move_keeps_components_untouched_without_templates(self):
+        self.module.module_bay = self.bay_b
+        self.module.save()
+        self.interface.refresh_from_db()
+        self.assertEqual(self.interface.name, 'eth0')
+        self.assertEqual(self.interface.device, self.device)
+
+    def test_save_without_move_skips_planner(self):
+        with patch('dcim.models.modules.ModuleMovePlan.from_module') as mock_plan:
+            self.module.serial = 'XYZ789'
+            self.module.save()
+        mock_plan.assert_not_called()
+
+    def test_save_level_move_with_type_change_raises_abort_request(self):
+        other_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Module Type N'
+        )
+        self.module.module_bay = self.bay_b
+        self.module.module_type = other_type
+        with self.assertRaises(AbortRequest):
+            self.module.save()
+
+
+class ModuleSaveRoutingTestCase(TestCase):
+    """Pins save() routing against concurrent, stale, deferred, and preset-pk instances."""
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        cls.device = create_test_device('Device A')
+        cls.device_b = create_test_device('Device B')
+        cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
+        cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
+        cls.bay_c = ModuleBay.objects.create(device=cls.device_b, name='Bay C')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
+        InterfaceTemplate.objects.create(
+            module_type=cls.module_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        cls.module = Module.objects.create(
+            device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
+        )
+        cls.child_bay = ModuleBay.objects.create(device=cls.device, module=cls.module, name='Child Bay 1')
+
+    def _create_nested_subtree(self):
+        child_module = Module(
+            device=self.device,
+            module_bay=self.child_bay,
+            module_type=self.module_type,
+        )
+        child_module._disable_replication = True
+        child_module.save()
+        root_interface = Interface.objects.get(module=self.module, name='eth0')
+        child_interface = Interface.objects.create(
+            device=self.device,
+            module=child_module,
+            name='child0',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        return child_module, root_interface, child_interface
+
+    @contextmanager
+    def _move_to_other_device_before_locked_save(self):
+        """Complete a competing move after save() routes the write but before the root lock."""
+        original_save_existing = Module._save_existing
+        moved = False
+
+        def save_existing(instance, *args, **kwargs):
+            nonlocal moved
+            if not moved:
+                moved = True
+                concurrent = Module.objects.get(pk=self.module.pk)
+                concurrent.device = self.device_b
+                concurrent.module_bay = self.bay_c
+                concurrent.save()
+            return original_save_existing(instance, *args, **kwargs)
+
+        with patch.object(Module, '_save_existing', autospec=True, side_effect=save_existing):
+            yield
+
+        self.assertTrue(moved)
+
+    def test_plain_save_uses_locked_placement_for_routing(self):
+        """A competing move landing before the root lock is resolved by the planner, not clobbered."""
+        child_module, root_interface, child_interface = self._create_nested_subtree()
+        stale = Module.objects.get(pk=self.module.pk)
+        stale.comments = 'Stale session edit'
+
+        with self._move_to_other_device_before_locked_save():
+            stale.save()
+
+        self.module.refresh_from_db()
+        child_module.refresh_from_db()
+        self.child_bay.refresh_from_db()
+        root_interface.refresh_from_db()
+        child_interface.refresh_from_db()
+        self.assertEqual((self.module.device_id, self.module.module_bay_id), (self.device.pk, self.bay_a.pk))
+        self.assertEqual(child_module.device_id, self.device.pk)
+        self.assertEqual((self.child_bay.device_id, self.child_bay.parent_id), (self.device.pk, self.bay_a.pk))
+        self.assertEqual(root_interface.device_id, self.device.pk)
+        self.assertEqual(child_interface.device_id, self.device.pk)
+        self.assertEqual(self.module.comments, 'Stale session edit')
+
+    def test_update_fields_are_checked_against_locked_placement(self):
+        """update_fields completeness is re-judged against the placement a competing move just committed."""
+        child_module, root_interface, child_interface = self._create_nested_subtree()
+        module = Module.objects.get(pk=self.module.pk)
+        module.module_bay = self.bay_b
+
+        with self._move_to_other_device_before_locked_save():
+            with self.assertRaises(AbortRequest):
+                module.save(update_fields=['module_bay'])
+
+        self.module.refresh_from_db()
+        child_module.refresh_from_db()
+        self.child_bay.refresh_from_db()
+        root_interface.refresh_from_db()
+        child_interface.refresh_from_db()
+        self.assertEqual((self.module.device_id, self.module.module_bay_id), (self.device_b.pk, self.bay_c.pk))
+        self.assertEqual(child_module.device_id, self.device_b.pk)
+        self.assertEqual((self.child_bay.device_id, self.child_bay.parent_id), (self.device_b.pk, self.bay_c.pk))
+        self.assertEqual(root_interface.device_id, self.device_b.pk)
+        self.assertEqual(child_interface.device_id, self.device_b.pk)
+
+    def test_stale_instance_plain_save_after_move_keeps_children_consistent(self):
+        instance_a = Module.objects.get(pk=self.module.pk)
+        instance_b = Module.objects.get(pk=self.module.pk)
+        instance_b.module_bay = self.bay_b
+        instance_b.save()
+
+        instance_a.comments = 'Stale session edit'
+        instance_a.save()
+
+        self.module.refresh_from_db()
+        self.child_bay.refresh_from_db()
+        self.assertEqual(self.module.comments, 'Stale session edit')
+        self.assertEqual(self.child_bay.parent_id, self.module.module_bay_id)
+        self.assertTrue(str(self.child_bay.path).startswith(f'{self.module.module_bay.path}.'))
+
+    def test_deferred_device_field_save_with_update_fields_succeeds(self):
+        module = Module.objects.defer('device').get(pk=self.module.pk)
+        module.status = ModuleStatusChoices.STATUS_OFFLINE
+        module.save(update_fields=['status'])
+        module.refresh_from_db()
+        self.assertEqual(module.status, ModuleStatusChoices.STATUS_OFFLINE)
+        self.assertEqual(module.module_bay, self.bay_a)
+
+    def test_out_of_band_placement_change_then_refresh_then_save_skips_planner(self):
+        # Routing always enters the locked read; a refreshed instance shows no delta there, so no plan is built.
+        Module.objects.filter(pk=self.module.pk).update(module_bay=self.bay_b)
+        self.module.refresh_from_db()
+        with patch.object(ModuleMovePlan, 'from_module') as mock_plan:
+            self.module.serial = 'REFRESHED1'
+            self.module.save()
+        mock_plan.assert_not_called()
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.serial, 'REFRESHED1')
+        self.assertEqual(self.module.module_bay, self.bay_b)
+
+    def test_preset_pk_save_with_unchanged_placement_does_not_replicate_components(self):
+        interface_count_before = Interface.objects.filter(module=self.module).count()
+        Module(
+            pk=self.module.pk, device=self.module.device, module_bay=self.module.module_bay,
+            module_type=self.module.module_type,
+        ).save()
+        self.assertEqual(Interface.objects.filter(module=self.module).count(), interface_count_before)
+
+    def test_preset_unused_pk_save_creates_module(self):
+        """A never-saved instance with an explicit unused pk is created, not treated as a move."""
+        unused_pk = Module.objects.order_by('-pk').first().pk + 1000
+        device = create_test_device('Device P')
+        bay = ModuleBay.objects.create(device=device, name='Preset Bay')
+        Module(pk=unused_pk, device=device, module_bay=bay, module_type=self.module_type).save()
+        self.assertTrue(Module.objects.filter(pk=unused_pk).exists())
+        self.assertTrue(Interface.objects.filter(module_id=unused_pk, name='eth0').exists())
+
+    def test_pk_reassignment_clone_creates_new_module(self):
+        """A fetched instance saved under a fresh unused pk creates a new row and keeps the original."""
+        clone = Module.objects.get(pk=self.module.pk)
+        device = create_test_device('Device C')
+        bay = ModuleBay.objects.create(device=device, name='Clone Bay')
+        clone.pk = Module.objects.order_by('-pk').first().pk + 1000
+        clone.device = device
+        clone.module_bay = bay
+        clone.save()
+        self.assertTrue(Module.objects.filter(pk=clone.pk).exists())
+        self.assertTrue(Module.objects.filter(pk=self.module.pk).exists())
+
+    def test_plain_save_after_concurrent_delete_recreates_module(self):
+        """A stale full save whose row was deleted underneath falls through to create, like a plain Django save."""
+        stale = Module.objects.get(pk=self.module.pk)
+        Module.objects.filter(pk=stale.pk).delete()
+        stale.comments = 'Recreated'
+        stale.save()
+        self.assertTrue(Module.objects.filter(pk=stale.pk, comments='Recreated').exists())
+
+    def test_cross_device_stale_plain_save_moves_subtree_back_consistently(self):
+        """
+        A stale full save that reverts a committed cross-device move runs the planner,
+        so the root module, child bays, and components land on one device together.
+        """
+        stale = Module.objects.get(pk=self.module.pk)
+
+        mover = Module.objects.get(pk=self.module.pk)
+        mover.device = self.device_b
+        mover.module_bay = self.bay_c
+        mover.save()
+
+        stale.comments = 'Stale full save'
+        stale.save()
+
+        self.module.refresh_from_db()
+        self.child_bay.refresh_from_db()
+        interface = Interface.objects.get(module=self.module, name='eth0')
+        self.assertEqual(self.module.comments, 'Stale full save')
+        self.assertEqual(self.module.device, self.device)
+        self.assertEqual(self.module.module_bay, self.bay_a)
+        self.assertEqual(self.child_bay.device, self.device)
+        self.assertEqual(self.child_bay.parent_id, self.bay_a.pk)
+        self.assertEqual(interface.device, self.device)
+
+
+class ModuleMoveRaceTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = create_test_device('Device A')
+        cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
+        cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
+        cls.bay_c = ModuleBay.objects.create(device=cls.device, name='Bay C')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
+        cls.module_1 = Module.objects.create(
+            device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
+        )
+        cls.module_2 = Module.objects.create(
+            device=cls.device, module_bay=cls.bay_b, module_type=cls.module_type
+        )
+
+    def test_bulk_update_into_occupied_bay_hits_db_constraint(self):
+        with self.assertRaises(IntegrityError):
+            with transaction.atomic():
+                Module.objects.filter(pk=self.module_2.pk).update(module_bay=self.bay_a)
+
+    def test_locked_validation_catches_occupied_bay_bypassing_clean(self):
+        # Simulate a TOCTOU window: clean() was never run (direct save), the target
+        # bay is genuinely occupied, and the in-save locked validation must reject.
+        self.module_2.module_bay = self.bay_a
+        with self.assertRaises(AbortRequest):
+            self.module_2.save()
+
+    def test_move_into_own_subtree_bypassing_clean_is_rejected(self):
+        child_bay = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
+        self.module_1.module_bay = child_bay
+        with self.assertRaises(AbortRequest):
+            self.module_1.save()
+
+    def test_full_clean_on_cyclic_hierarchy_raises_validation_error_promptly(self):
+        # A two-module cycle created via .update() (bypassing clean()) must raise promptly, not hang.
+        child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
+        child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2')
+        Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2)
+        Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1)
+        module = Module.objects.get(pk=self.module_1.pk)
+        module.module_bay = self.bay_c
+        with fail_after(15):
+            with self.assertRaises(ValidationError) as cm:
+                module.full_clean()
+        self.assertIn('module_bay', cm.exception.message_dict)
+        self.assertIn('contains a cycle', str(cm.exception.message_dict['module_bay']))
+
+    def test_move_into_cyclic_hierarchy_bypassing_clean_raises_abort_request(self):
+        # Same cycle, reached via direct save() (clean() never runs); must abort, not hang.
+        child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
+        child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2')
+        Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2)
+        Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1)
+        self.module_1.module_bay = self.bay_c
+        with fail_after(15):
+            with self.assertRaises(AbortRequest):
+                self.module_1.save()
+
+    def test_create_in_cyclic_hierarchy_with_token_template_raises_abort_request(self):
+        # _save_new()'s template resolution walks the target bay's ancestry; a cycle must abort, not hang.
+        child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1')
+        child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2')
+        Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2)
+        Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1)
+        token_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Module Type Token'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=token_type, name='eth{module}/0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        target_bay = ModuleBay.objects.create(
+            device=self.device, module=self.module_1, name='Child Bay 3', position='3'
+        )
+        # Fresh fetch so the ancestry walk reads database state, not cached relations
+        target_bay = ModuleBay.objects.get(pk=target_bay.pk)
+        module = Module(device=self.device, module_bay=target_bay, module_type=token_type)
+        with fail_after(15):
+            with self.assertRaises(AbortRequest) as cm:
+                module.save()
+        self.assertIn('contains a cycle', cm.exception.message)
+
+    def test_lock_rediscovers_membership_added_after_planning(self):
+        old = Module.objects.get(pk=self.module_1.pk)
+        new = Module.objects.get(pk=self.module_1.pk)
+        new.module_bay = ModuleBay.objects.create(device=self.device, name='Bay C')
+        plan = ModuleMovePlan.from_module(old_module=old, new_module=new)
+        late_interface = Interface.objects.create(
+            device=self.device, module=self.module_1, name='late0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        with transaction.atomic():
+            plan.lock()
+        self.assertIn(late_interface.pk, [obj.pk for obj in plan.components[Interface]])
+
+    def test_locked_device_get_does_not_exist_raises_abort_request(self):
+        # Same TOCTOU, at the device row lock acquired by ModuleMovePlan.lock(): the
+        # single-statement filter(pk__in=...) form returns fewer rows than expected pks.
+        self.module_1.module_bay = self.bay_c
+        with patch.object(Device.objects, 'select_for_update') as mock_sfu:
+            mock_sfu.return_value.filter.return_value.order_by.return_value = []
+            with self.assertRaises(AbortRequest):
+                self.module_1.save()
+
+
+class ModuleMoveSaveContractTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = create_test_device('Device A')
+        cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A')
+        cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
+        cls.module = Module.objects.create(
+            device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type
+        )
+
+    def test_update_fields_excluding_module_bay_saves_without_moving(self):
+        # A changed placement field left out of update_fields is never persisted, so no move happens.
+        self.module.module_bay = self.bay_b
+        self.module.serial = 'ABC123'
+        self.module.save(update_fields=['serial'])
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.serial, 'ABC123')
+        self.assertEqual(self.module.module_bay, self.bay_a)
+
+    def test_update_fields_including_placement_moves(self):
+        self.module.module_bay = self.bay_b
+        self.module.save(update_fields=['device', 'module_bay'])
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.module_bay, self.bay_b)
+
+    def test_deadlock_is_translated_to_abort_request(self):
+        deadlock = OperationalError('Simulated deadlock error')
+        deadlock.__cause__ = type('FakeDeadlock', (Exception,), {'sqlstate': '40P01'})()
+        self.module.module_bay = self.bay_b
+        with patch.object(ModuleMovePlan, 'lock', side_effect=deadlock):
+            with self.assertRaises(AbortRequest):
+                self.module.save()
+
+    def test_non_deadlock_operational_error_propagates(self):
+        error = OperationalError('Simulated connection error')
+        self.module.module_bay = self.bay_b
+        with patch.object(ModuleMovePlan, 'lock', side_effect=error):
+            with self.assertRaises(OperationalError):
+                self.module.save()
+
+    def test_post_save_signals_use_write_alias(self):
+        ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1')
+        self.module.module_bay = self.bay_b
+        with patch('dcim.models.module_moves.post_save') as mock_signal:
+            self.module.save()
+        self.assertTrue(mock_signal.send.called)
+        for call in mock_signal.send.call_args_list:
+            self.assertEqual(call.kwargs['using'], router.db_for_write(ModuleBay))
+
+    def test_move_to_deleted_bay_raises_abort_request(self):
+        target = ModuleBay.objects.create(device=self.device, name='Bay C')
+        self.module.module_bay = target
+        ModuleBay.objects.filter(pk=target.pk).delete()
+        with self.assertRaises(AbortRequest):
+            self.module.save()
+
+    def test_partial_update_fields_after_completed_move_succeeds(self):
+        self.module.module_bay = self.bay_b
+        self.module.save()
+        self.module.serial = 'A1'
+        self.module.save(update_fields=['serial'])
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.serial, 'A1')
+        self.assertEqual(self.module.module_bay, self.bay_b)
+
+    def test_update_fields_with_module_bay_name_moves_same_device(self):
+        child_bay = ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1')
+        self.module.module_bay = self.bay_b
+        self.module.save(update_fields=['module_bay'])
+        self.module.refresh_from_db()
+        child_bay.refresh_from_db()
+        self.assertEqual(self.module.module_bay, self.bay_b)
+        self.assertEqual(child_bay.parent_id, self.bay_b.pk)
+
+    def test_update_fields_with_attnames_moves_cross_device(self):
+        device_b = create_test_device('Device B')
+        bay_c = ModuleBay.objects.create(device=device_b, name='Bay C')
+        self.module.device = device_b
+        self.module.module_bay = bay_c
+        self.module.save(update_fields=['device_id', 'module_bay_id'])
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.device, device_b)
+        self.assertEqual(self.module.module_bay, bay_c)
+
+    def test_update_fields_missing_device_rejects_cross_device_delta(self):
+        device_b = create_test_device('Device B')
+        bay_c = ModuleBay.objects.create(device=device_b, name='Bay C')
+        self.module.device = device_b
+        self.module.module_bay = bay_c
+        with self.assertRaises(AbortRequest):
+            self.module.save(update_fields=['module_bay'])
+
+    def test_update_fields_incomplete_against_current_placement_rejects(self):
+        """
+        update_fields completeness is judged against the locked database placement,
+        not the placement the saving instance last observed.
+        """
+        device_b = create_test_device('Device B')
+        bay_c = ModuleBay.objects.create(device=device_b, name='Bay C')
+        stale = Module.objects.get(pk=self.module.pk)
+
+        mover = Module.objects.get(pk=self.module.pk)
+        mover.device = device_b
+        mover.module_bay = bay_c
+        mover.save()
+
+        stale.module_bay = self.bay_b
+        with self.assertRaises(AbortRequest):
+            stale.save(update_fields=['module_bay'])
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.device, device_b)
+        self.assertEqual(self.module.module_bay, bay_c)
+
+    def test_update_fields_with_unchanged_placement_field_saves_without_moving(self):
+        """
+        A placement field listed in update_fields with an unchanged value is written as-is;
+        a diverged placement field left out of update_fields never triggers a move.
+        """
+        self.module.module_bay = self.bay_b
+        self.module.serial = 'NOMOVE1'
+        with patch.object(ModuleMovePlan, 'from_module') as mock_plan:
+            self.module.save(update_fields=['device', 'serial'])
+        mock_plan.assert_not_called()
+        self.module.refresh_from_db()
+        self.assertEqual(self.module.serial, 'NOMOVE1')
+        self.assertEqual(self.module.module_bay, self.bay_a)
+
+
+class ModuleMoveRenameTestCase(TestCase):
+
+    @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 1', slug='site-1')
+        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.line_card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Line Card')
+        InterfaceTemplate.objects.create(
+            module_type=cls.line_card_type,
+            name='Ethernet{module}/1',
+            label='Port {module}',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=cls.line_card_type, name='SFP bay {module}/1', position='{module}/1'
+        )
+
+        cls.sfp_type = ModuleType.objects.create(manufacturer=manufacturer, model='SFP')
+        InterfaceTemplate.objects.create(
+            module_type=cls.sfp_type, name='SFP {module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+
+        cls.device = Device.objects.create(name='Chassis A', device_type=device_type, role=role, site=site)
+        cls.slot_1 = cls.device.modulebays.get(name='Slot 1')
+        cls.slot_2 = cls.device.modulebays.get(name='Slot 2')
+        cls.line_card = Module.objects.create(
+            device=cls.device, module_bay=cls.slot_1, module_type=cls.line_card_type
+        )
+        cls.sfp_bay = cls.line_card.modulebays.get(name='SFP bay 1/1')
+        cls.sfp_module = Module.objects.create(
+            device=cls.device, module_bay=cls.sfp_bay, module_type=cls.sfp_type
+        )
+
+    def _move_line_card_to_slot_2(self):
+        self.line_card.module_bay = self.slot_2
+        self.line_card.full_clean()
+        self.line_card.save()
+
+    def test_same_device_move_renames_templated_interface(self):
+        interface = self.line_card.interfaces.get(name='Ethernet1/1')
+        self._move_line_card_to_slot_2()
+        interface.refresh_from_db()
+        self.assertEqual(interface.name, 'Ethernet2/1')
+        self.assertEqual(interface.label, 'Port 2')
+        self.assertEqual(interface._name, naturalize_interface('Ethernet2/1', max_length=100))
+
+    def test_move_renames_nested_bay_and_grandchild_components(self):
+        sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
+        self._move_line_card_to_slot_2()
+        self.sfp_bay.refresh_from_db()
+        self.assertEqual(self.sfp_bay.name, 'SFP bay 2/1')
+        self.assertEqual(self.sfp_bay.position, '2/1')
+        sfp_interface.refresh_from_db()
+        self.assertEqual(sfp_interface.name, 'SFP 2/1')
+
+    def test_manually_renamed_component_is_preserved(self):
+        interface = self.line_card.interfaces.get(name='Ethernet1/1')
+        interface.name = 'uplink-core'
+        interface.save()
+        self._move_line_card_to_slot_2()
+        interface.refresh_from_db()
+        self.assertEqual(interface.name, 'uplink-core')
+
+    def test_manually_changed_label_is_preserved_while_name_renames(self):
+        interface = self.line_card.interfaces.get(name='Ethernet1/1')
+        interface.label = 'Uplink'
+        interface.save()
+        self._move_line_card_to_slot_2()
+        interface.refresh_from_db()
+        self.assertEqual(interface.name, 'Ethernet2/1')
+        self.assertEqual(interface.label, 'Uplink')
+
+    def test_ambiguous_template_match_is_preserved(self):
+        # A second template resolving to the same old name makes the match ambiguous
+        InterfaceTemplate.objects.create(
+            module_type=self.line_card_type, name='Ethernet1/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        interface = self.line_card.interfaces.get(name='Ethernet1/1')
+        self._move_line_card_to_slot_2()
+        interface.refresh_from_db()
+        self.assertEqual(interface.name, 'Ethernet1/1')
+
+    def test_move_after_module_type_change_preserves_names(self):
+        other_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Other Card'
+        )
+        self.line_card.module_type = other_type
+        self.line_card.save()
+        interface = self.line_card.interfaces.get(name='Ethernet1/1')
+        self._move_line_card_to_slot_2()
+        interface.refresh_from_db()
+        self.assertEqual(interface.name, 'Ethernet1/1')
+
+    def test_manually_changed_bay_position_stops_rename_cascade(self):
+        self.sfp_bay.position = 'X'
+        self.sfp_bay.save()
+        sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
+        self._move_line_card_to_slot_2()
+        self.sfp_bay.refresh_from_db()
+        self.assertEqual(self.sfp_bay.position, 'X')
+        sfp_interface.refresh_from_db()
+        self.assertEqual(sfp_interface.name, 'SFP 1/1')
+
+    def test_duplicate_final_names_within_moved_set_fail(self):
+        Interface.objects.create(
+            device=self.device, module=self.line_card, name='Ethernet2/1',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        self.line_card.module_bay = self.slot_2
+        with self.assertRaises(ValidationError):
+            self.line_card.full_clean()
+
+    def test_conflict_with_existing_destination_component_fails(self):
+        Interface.objects.create(
+            device=self.device, name='Ethernet2/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        self.line_card.module_bay = self.slot_2
+        with self.assertRaises(ValidationError):
+            self.line_card.full_clean()
+
+    def test_rename_chain_collision_is_rejected(self):
+        # 'E{module}/1' resolves to E1/1 in slot 1 and E2/1 in slot 2, while 'E2/{module}'
+        # resolves to E2/1 in slot 1 and E2/2 in slot 2: E1/1 -> E2/1 while E2/1 -> E2/2.
+        # Applying both renames on the same device in one statement is order-dependent.
+        InterfaceTemplate.objects.create(
+            module_type=self.line_card_type, name='E2/{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        InterfaceTemplate.objects.create(
+            module_type=self.line_card_type, name='E{module}/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        Interface.objects.create(
+            device=self.device, module=self.line_card, name='E2/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        Interface.objects.create(
+            device=self.device, module=self.line_card, name='E1/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        self.line_card.module_bay = self.slot_2
+        with self.assertRaises(ValidationError) as cm:
+            self.line_card.full_clean()
+        self.assertIn('current name of another moved', str(cm.exception))
+
+    def test_bay_rename_chain_collision_is_rejected(self):
+        # 'Bay E2/{module}' resolves to Bay E2/1 in slot 1 and Bay E2/2 in slot 2, while
+        # 'Bay E{module}/1' resolves to Bay E1/1 in slot 1 and Bay E2/1 in slot 2: Bay E1/1
+        # -> Bay E2/1 while Bay E2/1 -> Bay E2/2. Applying both renames in one same-device
+        # bulk statement would collide on the destination namespace depending on row order.
+        ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E2/{module}')
+        ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E{module}/1')
+        ModuleBay.objects.create(device=self.device, module=self.line_card, name='Bay E2/1')
+        ModuleBay.objects.create(device=self.device, module=self.line_card, name='Bay E1/1')
+        self.line_card.module_bay = self.slot_2
+        with self.assertRaises(ValidationError) as cm:
+            self.line_card.full_clean()
+        self.assertIn('current name of another moved module bay', str(cm.exception))
+
+    def test_rename_exceeding_name_max_length_is_rejected(self):
+        name_limit = Interface._meta.get_field('name').max_length
+        template_name_limit = InterfaceTemplate._meta.get_field('name').max_length
+        position_limit = ModuleBay._meta.get_field('position').max_length
+        prefix = 'A' * (template_name_limit - len('{module}'))
+        long_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Oversized Name Card'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=long_type, name=f'{prefix}{{module}}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        long_bay = ModuleBay.objects.create(device=self.device, name='Long Bay', position='X' * position_limit)
+        module = Module.objects.create(device=self.device, module_bay=self.slot_2, module_type=long_type)
+        interface = module.interfaces.get()
+        self.assertLessEqual(len(interface.name), name_limit)
+
+        module.module_bay = long_bay
+        with self.assertRaises(ValidationError) as cm:
+            module.full_clean()
+        self.assertIn(str(interface), str(cm.exception))
+
+    def test_rename_exceeding_position_max_length_is_rejected(self):
+        position_limit = ModuleBay._meta.get_field('position').max_length
+        template_position_limit = ModuleBayTemplate._meta.get_field('position').max_length
+        prefix = 'B' * (template_position_limit - len('{module}'))
+        long_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Oversized Position Card'
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=long_type, name='Nested Bay {module}', position=f'{prefix}{{module}}'
+        )
+        long_bay = ModuleBay.objects.create(device=self.device, name='Long Bay', position='Y' * position_limit)
+        module = Module.objects.create(device=self.device, module_bay=self.slot_2, module_type=long_type)
+        bay = module.modulebays.get()
+
+        module.module_bay = long_bay
+        with self.assertRaises(ValidationError) as cm:
+            module.full_clean()
+        self.assertIn(str(bay), str(cm.exception))
+
+    def test_leaf_token_left_raw_matches_fresh_walker_resolution(self):
+        # A bay whose stored position literally contains an unresolved {module} token
+        # (bypassing normal template-driven creation) must resolve its own children's
+        # names identically to a fresh get_module_bay_positions() call, both before and
+        # after a move, so a later move can still recognize the template match.
+        odd_bay = ModuleBay.objects.create(
+            device=self.device, module=self.line_card, name='Odd Bay', position='{module}A'
+        )
+        child_module = Module.objects.create(device=self.device, module_bay=odd_bay, module_type=self.sfp_type)
+        child_interface = child_module.interfaces.get()
+
+        self._move_line_card_to_slot_2()
+        child_interface.refresh_from_db()
+        odd_bay.refresh_from_db()
+        expected_name = resolve_module_placeholder('SFP {module}', get_module_bay_positions(odd_bay))
+        self.assertEqual(child_interface.name, expected_name)
+
+    def test_second_move_still_renames_child_of_raw_token_bay(self):
+        # Regression pin: a second move of the same module must still template-match and
+        # rename a child nested under a raw-token bay, not silently stop renaming.
+        two_token_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Two Token SFP'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=two_token_type, name='SFP {module}/{module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        odd_bay = ModuleBay.objects.create(
+            device=self.device, module=self.line_card, name='Odd Bay', position='{module}A'
+        )
+        child_module = Module.objects.create(device=self.device, module_bay=odd_bay, module_type=two_token_type)
+        child_interface = child_module.interfaces.get()
+        self.assertEqual(child_interface.name, 'SFP 1/{module}A')
+
+        self._move_line_card_to_slot_2()
+        child_interface.refresh_from_db()
+        self.assertEqual(child_interface.name, 'SFP 2/{module}A')
+
+        self.line_card.module_bay = self.slot_1
+        self.line_card.full_clean()
+        self.line_card.save()
+        child_interface.refresh_from_db()
+        self.assertEqual(child_interface.name, 'SFP 1/{module}A')
+
+    def test_move_into_raw_token_bay_resolves_ancestor_from_child(self):
+        """
+        A raw {module} token on the DESTINATION bay resolves from the planned child
+        position below it, matching a fresh post-move walker resolution exactly.
+        """
+        raw_slot = ModuleBay.objects.create(device=self.device, name='Raw Slot', position='{module}R')
+        b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
+        two_token_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Two Token'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=two_token_type, name='SFP {module}/{module}',
+            type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+        )
+        child_module = Module.objects.create(
+            device=self.device, module_bay=b_bay, module_type=two_token_type
+        )
+        child_interface = child_module.interfaces.get(name='SFP 1/B')
+
+        self.line_card.module_bay = raw_slot
+        self.line_card.full_clean()
+        self.line_card.save()
+
+        child_interface.refresh_from_db()
+        self.assertEqual(child_interface.name, 'SFP BR/B')
+        self.assertNotIn('{module}', child_interface.name)
+        fresh_chain = get_module_bay_positions(ModuleBay.objects.get(pk=b_bay.pk))
+        self.assertEqual(child_interface.name, resolve_module_placeholder('SFP {module}/{module}', fresh_chain))
+        # Leaf-raw parity: the moved module's own component sees the destination
+        # bay's token unresolved, exactly as an install into that bay would
+        root_interface = self.line_card.interfaces.get(name__startswith='Ethernet')
+        self.assertEqual(root_interface.name, 'Ethernet{module}R/1')
+
+    def test_move_under_multiple_raw_token_ancestors_resolves_full_chain(self):
+        """
+        Every ancestor level carrying a raw {module} token inherits from the resolved
+        position below it, across more than one level.
+        """
+        bare_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Bare Carrier'
+        )
+        three_token_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Three Token'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=three_token_type, name='T{module}.{module}.{module}',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        # Source: depth-3 chain ['7', '8', 'B'] so the three-token template resolves
+        s1 = ModuleBay.objects.create(device=self.device, name='S1', position='7')
+        mid_src = Module.objects.create(device=self.device, module_bay=s1, module_type=bare_type)
+        s2 = ModuleBay.objects.create(device=self.device, module=mid_src, name='S2', position='8')
+        carrier = Module.objects.create(device=self.device, module_bay=s2, module_type=bare_type)
+        c_bay = ModuleBay.objects.create(device=self.device, module=carrier, name='C Bay', position='B')
+        child_module = Module.objects.create(
+            device=self.device, module_bay=c_bay, module_type=three_token_type
+        )
+        child_interface = child_module.interfaces.get(name='T7.8.B')
+        # Destination: two stacked raw-token ancestors
+        g_bay = ModuleBay.objects.create(device=self.device, name='G Bay', position='{module}X')
+        mid_dst = Module.objects.create(device=self.device, module_bay=g_bay, module_type=bare_type)
+        r_bay = ModuleBay.objects.create(device=self.device, module=mid_dst, name='R Bay', position='{module}R')
+
+        carrier.module_bay = r_bay
+        carrier.full_clean()
+        carrier.save()
+
+        child_interface.refresh_from_db()
+        self.assertEqual(child_interface.name, 'TBRX.BR.B')
+        self.assertNotIn('{module}', child_interface.name)
+        fresh_chain = get_module_bay_positions(ModuleBay.objects.get(pk=c_bay.pk))
+        self.assertEqual(fresh_chain, ['BRX', 'BR', 'B'])
+        self.assertEqual(
+            child_interface.name, resolve_module_placeholder('T{module}.{module}.{module}', fresh_chain)
+        )
+
+    def test_move_to_shallower_bay_with_unresolvable_template_is_rejected(self):
+        """
+        A component whose name matched a source template that cannot resolve at the
+        destination depth rejects the move instead of silently keeping the stale name.
+        """
+        two_token_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Two Token'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=two_token_type, name='SFP {module}/{module}',
+            type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+        )
+        b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
+        child_module = Module.objects.create(
+            device=self.device, module_bay=b_bay, module_type=two_token_type
+        )
+        shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
+
+        child_module.module_bay = shallow
+        with self.assertRaises(ValidationError) as cm:
+            child_module.full_clean()
+        self.assertIn('cannot be resolved', str(cm.exception))
+        with self.assertRaises(AbortRequest):
+            child_module.save()
+        child_module.refresh_from_db()
+        self.assertEqual(child_module.module_bay_id, b_bay.pk)
+        self.assertTrue(child_module.interfaces.filter(name='SFP 1/B').exists())
+
+    def test_matched_label_unresolvable_at_destination_rejects_move(self):
+        """
+        A label matching its source template resolution rejects the move when the
+        label template cannot resolve at the destination depth.
+        """
+        label_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Label Type'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=label_type, name='N{module}', label='L{module}/{module}',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
+        child_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=label_type)
+        interface = child_module.interfaces.get(name='NB')
+        self.assertEqual(interface.label, 'L1/B')
+        shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
+
+        child_module.module_bay = shallow
+        with self.assertRaises(ValidationError):
+            child_module.full_clean()
+
+    def test_unmatched_label_with_unresolvable_template_still_moves(self):
+        """
+        A manually customized label never source-matches, so an unresolvable label
+        template does not block the move and the label is preserved.
+        """
+        label_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Label Type'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=label_type, name='N{module}', label='L{module}/{module}',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
+        child_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=label_type)
+        interface = child_module.interfaces.get(name='NB')
+        interface.label = 'Custom'
+        interface.save()
+        shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
+
+        child_module.module_bay = shallow
+        child_module.full_clean()
+        child_module.save()
+        interface.refresh_from_db()
+        self.assertEqual(interface.name, 'NX')
+        self.assertEqual(interface.label, 'Custom')
+
+    def test_matched_bay_position_unresolvable_at_destination_rejects_move(self):
+        """
+        A module bay position matching its source template resolution rejects the move
+        when the position template cannot resolve at the destination depth.
+        """
+        pos_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Pos Type'
+        )
+        ModuleBayTemplate.objects.create(module_type=pos_type, name='PB', position='{module}/{module}')
+        b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B')
+        pos_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=pos_type)
+        self.assertEqual(pos_module.modulebays.get(name='PB').position, '1/B')
+        shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X')
+
+        pos_module.module_bay = shallow
+        with self.assertRaises(ValidationError):
+            pos_module.full_clean()
+
+
+class ModuleCrossDeviceBlockerTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device_a = create_test_device('Device A')
+        cls.device_b = create_test_device('Device B')
+        cls.bay_a = ModuleBay.objects.create(device=cls.device_a, name='Bay A')
+        cls.bay_b = ModuleBay.objects.create(device=cls.device_b, name='Bay B')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M')
+        cls.module = Module.objects.create(
+            device=cls.device_a, module_bay=cls.bay_a, module_type=cls.module_type
+        )
+        cls.interface = Interface.objects.create(
+            device=cls.device_a, module=cls.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+
+    def _assert_move_blocked(self, token):
+        self.module.device = self.device_b
+        self.module.module_bay = self.bay_b
+        with self.assertRaises(ValidationError) as cm:
+            self.module.full_clean()
+        self.assertIn('cannot be moved to a different device', str(cm.exception))
+        self.assertIn(token, str(cm.exception))
+
+    def _assert_move_allowed(self):
+        self.module.device = self.device_b
+        self.module.module_bay = self.bay_b
+        self.module.full_clean()
+
+    def test_cable_blocks(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()
+        self._assert_move_blocked('cabled')
+
+    def test_mark_connected_blocks(self):
+        self.interface.mark_connected = True
+        self.interface.save()
+        self._assert_move_blocked('cabled or connection-marked')
+
+    def test_ip_address_blocks(self):
+        IPAddress.objects.create(address='192.0.2.1/32', assigned_object=self.interface)
+        self._assert_move_blocked('IP addresses')
+
+    def test_fhrp_group_assignment_blocks(self):
+        group = FHRPGroup.objects.create(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=1)
+        FHRPGroupAssignment.objects.create(group=group, interface=self.interface, priority=100)
+        self._assert_move_blocked('FHRP')
+
+    def test_tunnel_termination_blocks(self):
+        tunnel = Tunnel.objects.create(name='Tunnel 1', encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP)
+        TunnelTermination.objects.create(tunnel=tunnel, termination=self.interface)
+        self._assert_move_blocked('tunnel')
+
+    def test_l2vpn_termination_blocks(self):
+        l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN)
+        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=self.interface)
+        self._assert_move_blocked('L2VPN')
+
+    def test_virtual_circuit_termination_blocks(self):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
+        vc_type = VirtualCircuitType.objects.create(name='VC Type 1', slug='vc-type-1')
+        vc = VirtualCircuit.objects.create(provider_network=provider_network, cid='VC 1', type=vc_type)
+        virtual_interface = Interface.objects.create(
+            device=self.device_a, module=self.module, name='vc0', type=InterfaceTypeChoices.TYPE_VIRTUAL
+        )
+        VirtualCircuitTermination.objects.create(virtual_circuit=vc, interface=virtual_interface)
+        self._assert_move_blocked('virtual circuit')
+
+    def test_wireless_link_blocks(self):
+        radio_a = Interface.objects.create(
+            device=self.device_a, module=self.module, name='radio0', type=InterfaceTypeChoices.TYPE_80211AC
+        )
+        radio_b = Interface.objects.create(
+            device=self.device_a, name='radio1', type=InterfaceTypeChoices.TYPE_80211AC
+        )
+        WirelessLink(interface_a=radio_a, interface_b=radio_b, ssid='LINK1').save()
+        self._assert_move_blocked('wireless links')
+
+    def test_wireless_lan_blocks(self):
+        wlan = WirelessLAN.objects.create(ssid='SSID1')
+        self.interface.wireless_lans.add(wlan)
+        self._assert_move_blocked('wireless LAN')
+
+    def test_untagged_vlan_blocks(self):
+        vlan = VLAN.objects.create(vid=100, name='VLAN 100')
+        self.interface.mode = InterfaceModeChoices.MODE_ACCESS
+        self.interface.untagged_vlan = vlan
+        self.interface.save()
+        self._assert_move_blocked('untagged VLAN')
+
+    def test_tagged_vlan_blocks(self):
+        vlan = VLAN.objects.create(vid=200, name='VLAN 200')
+        self.interface.mode = InterfaceModeChoices.MODE_TAGGED
+        self.interface.save()
+        self.interface.tagged_vlans.add(vlan)
+        self._assert_move_blocked('tagged VLANs')
+
+    def test_qinq_svlan_blocks(self):
+        svlan = VLAN.objects.create(vid=999, name='SVLAN 999')
+        self.interface.mode = InterfaceModeChoices.MODE_Q_IN_Q
+        self.interface.qinq_svlan = svlan
+        self.interface.save()
+        self._assert_move_blocked('Q-in-Q')
+
+    def test_vlan_translation_policy_blocks(self):
+        policy = VLANTranslationPolicy.objects.create(name='Policy 1')
+        self.interface.vlan_translation_policy = policy
+        self.interface.save()
+        self._assert_move_blocked('VLAN translation')
+
+    def test_vdc_assignment_blocks(self):
+        vdc = VirtualDeviceContext.objects.create(device=self.device_a, name='VDC 1', status='active')
+        self.interface.vdcs.add(vdc)
+        self._assert_move_blocked('VDC')
+
+    def test_vrf_blocks(self):
+        vrf = VRF.objects.create(name='VRF 1')
+        self.interface.vrf = vrf
+        self.interface.save()
+        self._assert_move_blocked('VRF')
+
+    def test_parent_outside_moved_set_blocks(self):
+        parent = Interface.objects.create(
+            device=self.device_a, name='parent0', type=InterfaceTypeChoices.TYPE_VIRTUAL
+        )
+        Interface.objects.create(
+            device=self.device_a, module=self.module, name='child0',
+            type=InterfaceTypeChoices.TYPE_VIRTUAL, parent=parent,
+        )
+        self._assert_move_blocked('boundary')
+
+    def test_bridge_outside_moved_set_blocks(self):
+        bridge = Interface.objects.create(
+            device=self.device_a, name='bridge0', type=InterfaceTypeChoices.TYPE_BRIDGE
+        )
+        self.interface.bridge = bridge
+        self.interface.save()
+        self._assert_move_blocked('boundary')
+
+    def test_lag_outside_moved_set_blocks(self):
+        lag = Interface.objects.create(
+            device=self.device_a, name='lag0', type=InterfaceTypeChoices.TYPE_LAG
+        )
+        self.interface.lag = lag
+        self.interface.save()
+        self._assert_move_blocked('boundary')
+
+    def test_nonmoved_member_of_moved_lag_blocks(self):
+        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
+        )
+        self._assert_move_blocked('boundary')
+
+    def test_split_port_mapping_blocks(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._assert_move_blocked('port mappings')
+
+    def test_split_power_outlet_blocks(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._assert_move_blocked('power outlet')
+
+    def test_attached_inventory_item_blocks(self):
+        InventoryItem.objects.create(device=self.device_a, name='Item 1', component=self.interface)
+        self._assert_move_blocked('inventory items')
+
+    def test_intra_module_bridge_pair_is_allowed(self):
+        other = Interface.objects.create(
+            device=self.device_a, module=self.module, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        self.interface.bridge = other
+        self.interface.save()
+        self._assert_move_allowed()
+
+    def test_intra_module_power_pair_is_allowed(self):
+        power_port = PowerPort.objects.create(device=self.device_a, module=self.module, name='PP 1')
+        PowerOutlet.objects.create(
+            device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port
+        )
+        self._assert_move_allowed()
+
+    def test_mac_address_is_allowed(self):
+        mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=self.interface)
+        self.interface.primary_mac_address = mac
+        self.interface.save()
+        self._assert_move_allowed()
+
+
+class ModuleCrossDeviceMoveTestCase(TestCase):
+
+    @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.line_card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Line Card')
+        InterfaceTemplate.objects.create(
+            module_type=cls.line_card_type, name='Ethernet{module}/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        power_port_template = PowerPortTemplate.objects.create(module_type=cls.line_card_type, name='PP 1')
+        PowerOutletTemplate.objects.create(
+            module_type=cls.line_card_type, name='Outlet 1', power_port=power_port_template
+        )
+        front_port_template = FrontPortTemplate.objects.create(
+            module_type=cls.line_card_type, name='Front 1', type=PortTypeChoices.TYPE_LC
+        )
+        rear_port_template = RearPortTemplate.objects.create(
+            module_type=cls.line_card_type, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1
+        )
+        PortTemplateMapping.objects.create(
+            module_type=cls.line_card_type,
+            front_port=front_port_template, front_port_position=1,
+            rear_port=rear_port_template, rear_port_position=1,
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=cls.line_card_type, name='SFP bay {module}/1', position='{module}/1'
+        )
+        cls.sfp_type = ModuleType.objects.create(manufacturer=manufacturer, model='SFP')
+        InterfaceTemplate.objects.create(
+            module_type=cls.sfp_type, name='SFP {module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+
+        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_b = cls.device_b.modulebays.get(name='Slot 2')
+        cls.line_card = Module.objects.create(
+            device=cls.device_a, module_bay=cls.slot_1_a, module_type=cls.line_card_type
+        )
+        cls.sfp_bay = cls.line_card.modulebays.get(name='SFP bay 1/1')
+        cls.sfp_module = Module.objects.create(
+            device=cls.device_a, module_bay=cls.sfp_bay, module_type=cls.sfp_type
+        )
+
+    def _move_to_device_b(self):
+        self.line_card.device = self.device_b
+        self.line_card.module_bay = self.slot_2_b
+        self.line_card.full_clean()
+        self.line_card.save()
+
+    def test_cross_device_move_updates_subtree(self):
+        sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
+        previous = sfp_interface.last_updated
+        self._move_to_device_b()
+
+        self.line_card.refresh_from_db()
+        self.assertEqual(self.line_card.device, self.device_b)
+        self.assertEqual(self.line_card.module_bay, self.slot_2_b)
+
+        self.sfp_module.refresh_from_db()
+        self.assertEqual(self.sfp_module.device, self.device_b)
+
+        self.sfp_bay.refresh_from_db()
+        self.assertEqual(self.sfp_bay.device, self.device_b)
+        self.assertEqual(self.sfp_bay._site, self.site_b)
+        self.assertEqual(self.sfp_bay.parent_id, self.slot_2_b.pk)
+        self.assertTrue(str(self.sfp_bay.path).startswith(f'{self.slot_2_b.path}.'))
+
+        sfp_interface.refresh_from_db()
+        self.assertEqual(sfp_interface.device, self.device_b)
+        self.assertEqual(sfp_interface._site, self.site_b)
+        self.assertGreater(sfp_interface.last_updated, previous)
+
+    def test_cross_device_move_renames_templated_components(self):
+        interface = self.line_card.interfaces.get(name='Ethernet1/1')
+        sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1')
+        self._move_to_device_b()
+        interface.refresh_from_db()
+        self.assertEqual(interface.name, 'Ethernet2/1')
+        sfp_interface.refresh_from_db()
+        self.assertEqual(sfp_interface.name, 'SFP 2/1')
+
+    def test_cross_device_move_applies_swap_chain_bay_renames(self):
+        """
+        A rename chain where one bay's new name equals another moved bay's current name
+        applies cleanly cross-device, with both bays renamed on the destination device.
+        """
+        # Templates resolve Bay E1/1 -> Bay E2/1 and Bay E2/1 -> Bay E2/2 across slots
+        ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E2/{module}')
+        ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E{module}/1')
+        bay_1 = ModuleBay.objects.create(device=self.device_a, module=self.line_card, name='Bay E1/1')
+        bay_2 = ModuleBay.objects.create(device=self.device_a, module=self.line_card, name='Bay E2/1')
+        self._move_to_device_b()
+        bay_1.refresh_from_db()
+        bay_2.refresh_from_db()
+        self.assertEqual(bay_1.name, 'Bay E2/1')
+        self.assertEqual(bay_2.name, 'Bay E2/2')
+        self.assertEqual(bay_1.device, self.device_b)
+        self.assertEqual(bay_2.device, self.device_b)
+
+    def test_cross_device_move_updates_port_mappings(self):
+        mapping = PortMapping.objects.get(front_port__module=self.line_card)
+        self._move_to_device_b()
+        mapping.refresh_from_db()
+        self.assertEqual(mapping.device, self.device_b)
+
+    def test_cross_device_move_keeps_intra_module_power_link(self):
+        outlet = self.line_card.poweroutlets.get(name='Outlet 1')
+        self._move_to_device_b()
+        outlet.refresh_from_db()
+        self.assertEqual(outlet.device, self.device_b)
+        self.assertEqual(outlet.power_port.device_id, self.device_b.pk)
+
+    def test_cross_device_move_moves_mac_address(self):
+        interface = self.line_card.interfaces.get(name='Ethernet1/1')
+        mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=interface)
+        self._move_to_device_b()
+        mac.refresh_from_db()
+        interface.refresh_from_db()
+        self.assertEqual(mac.assigned_object, interface)
+        self.assertEqual(interface.device, self.device_b)
+
+    def test_cross_device_move_recomputes_counters(self):
+        self._move_to_device_b()
+        self.device_a.refresh_from_db()
+        self.device_b.refresh_from_db()
+        self.assertEqual(self.device_a.interface_count, 0)
+        self.assertEqual(self.device_b.interface_count, 2)
+        self.assertEqual(self.device_a.module_bay_count, 2)
+        self.assertEqual(self.device_b.module_bay_count, 3)
+
+    def test_move_query_count_independent_of_destination_size(self):
+        def move_and_count(destination_device, destination_bay):
+            module = Module.objects.create(
+                device=self.device_a, module_bay=self.device_a.modulebays.get(name='Slot 2'),
+                module_type=self.sfp_type,
+            )
+            module.device = destination_device
+            module.module_bay = destination_bay
+            module.full_clean()
+            with CaptureQueriesContext(connection) as ctx:
+                module.save()
+            return len(ctx.captured_queries)
+
+        small_count = move_and_count(self.device_b, self.slot_2_b)
+        big_device = Device.objects.create(
+            name='Warehouse', device_type=self.device_b.device_type, role=self.device_b.role,
+            site=self.site_b,
+        )
+        ModuleBay.objects.bulk_create([
+            ModuleBay(device=big_device, name=f'Storage Bay {i}') for i in range(1, 201)
+        ])
+        target_bay = ModuleBay.objects.create(device=big_device, name='Target Bay')
+        big_count = move_and_count(big_device, target_bay)
+        self.assertEqual(small_count, big_count)
+
+    def test_move_query_count_independent_of_same_type_child_count(self):
+        """
+        Moving a module with several installed children of the same module_type issues
+        the same number of template-table queries as moving one with a single child: the
+        per-pass template lookup is cached per module_type, not repeated per module.
+        """
+        multi_type = ModuleType.objects.create(
+            manufacturer=self.line_card_type.manufacturer, model='Multi-Slot Card'
+        )
+        for i in range(1, 4):
+            ModuleBayTemplate.objects.create(module_type=multi_type, name=f'Child Bay {i}', position=str(i))
+
+        def build_and_move(child_count):
+            device = create_test_device(f'Card Device {child_count}')
+            card_bay = ModuleBay.objects.create(device=device, name='Card Bay')
+            target_bay = ModuleBay.objects.create(device=device, name='Target Bay')
+            card = Module.objects.create(device=device, module_bay=card_bay, module_type=multi_type)
+            for child_bay in card.modulebays.order_by('name')[:child_count]:
+                Module.objects.create(device=device, module_bay=child_bay, module_type=self.sfp_type)
+            card.module_bay = target_bay
+            card.full_clean()
+            with CaptureQueriesContext(connection) as ctx:
+                card.save()
+            return sum(1 for query in ctx.captured_queries if 'template' in query['sql'].lower())
+
+        one_child_queries = build_and_move(1)
+        three_children_queries = build_and_move(3)
+        self.assertEqual(one_child_queries, three_children_queries)
+
+    def test_cross_device_move_refreshes_bay_sort_path(self):
+        """
+        The trigger-maintained sort_path of a moved nested bay reflects the
+        destination hierarchy and the renamed chain after reparent plus rename.
+        """
+        old_sort_path = self.sfp_bay.sort_path
+        self._move_to_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.assertTrue(str(moved_bay.path).startswith(f'{dest_bay.path}.'))
+        self.assertNotEqual(moved_bay.sort_path, old_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))

+ 52 - 13
netbox/dcim/utils.py

@@ -8,27 +8,66 @@ from django.utils.translation import gettext as _
 from dcim.constants import MODULE_TOKEN
 
 
-def get_module_bay_positions(module_bay):
+def inherit_module_token(position, parent_positions):
+    """
+    Resolve a single {module} token in a bay position by inheriting from the position
+    one level deeper in a module bay hierarchy. Returns position unchanged unless
+    parent_positions is non-empty and position contains {module}, in which case the
+    token is substituted with parent_positions[-1].
+
+    Used by resolve_position_chain(), the single inheritance implementation shared by
+    get_module_bay_positions() and the module move planner.
     """
-    Given a module bay, traverse up the module hierarchy and return
-    a list of bay position strings from root to leaf, resolving any
-    {module} tokens in each position using the parent position
-    (position inheritance).
+    if parent_positions and MODULE_TOKEN in position:
+        return position.replace(MODULE_TOKEN, parent_positions[-1])
+    return position
+
+
+def get_module_bay_raw_positions(module_bay):
+    """
+    Given a module bay, traverse up the module hierarchy and return the stored
+    (unresolved) bay position strings from root to leaf.
+
+    Raises ValueError if the module bay hierarchy contains a cycle.
     """
     positions = []
+    visited = set()
     while module_bay:
-        pos = module_bay.position or ''
-        if positions and MODULE_TOKEN in pos:
-            pos = pos.replace(MODULE_TOKEN, positions[-1])
-        positions.append(pos)
-        if module_bay.module:
-            module_bay = module_bay.module.module_bay
-        else:
-            module_bay = None
+        if module_bay.pk in visited:
+            raise ValueError(_("Module bay hierarchy contains a cycle."))
+        visited.add(module_bay.pk)
+        positions.append(module_bay.position or '')
+        module_bay = module_bay.module.module_bay if module_bay.module else None
     positions.reverse()
     return positions
 
 
+def resolve_position_chain(raw_positions):
+    """
+    Apply leaf-to-root {module} token inheritance over a root-to-leaf list of raw bay
+    positions: each position inherits from the resolved position one level deeper, and
+    the leaf's own token is never resolved. Shared by get_module_bay_positions() and
+    the module move planner so a planned chain always equals what a fresh walk
+    computes once the planned positions are stored.
+    """
+    resolved = []
+    for position in reversed(raw_positions):
+        resolved.append(inherit_module_token(position, resolved))
+    resolved.reverse()
+    return resolved
+
+
+def get_module_bay_positions(module_bay):
+    """
+    Given a module bay, traverse up the module hierarchy and return a list of bay
+    position strings from root to leaf, resolving any {module} tokens in each
+    position using the parent position (position inheritance).
+
+    Raises ValueError if the module bay hierarchy contains a cycle.
+    """
+    return resolve_position_chain(get_module_bay_raw_positions(module_bay))
+
+
 def resolve_module_placeholder(value, positions):
     """
     Resolve {module} placeholder tokens in a string using the given