Fixes #15289
This commit is contained in:
parent
036456dc54
commit
cfbbceea4d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
if not is_new:
|
||||
old_module_bay_id = Module.objects.filter(pk=self.pk).values_list(
|
||||
'module_bay_id', flat=True
|
||||
).first()
|
||||
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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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):
|
||||
"""
|
||||
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).
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue