Coverage for netbox_interface_name_rules / engine.py: 100.00%
390 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-16 10:13 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3"""Core renaming engine — rule lookup and interface rename logic.
5This module is imported lazily by rename_triggers.py so that model imports happen
6after Django is fully initialised.
7"""
9import contextlib
10import logging
11from collections import defaultdict
12from collections.abc import Iterator
13from dataclasses import dataclass, replace
14from typing import NamedTuple
16from django.core.exceptions import ValidationError
17from django.db import DatabaseError
19from . import family as family_ops
20from . import name_template, naming, rule_selection
21from .family import template_names as family_template_names
22from .regex_safety import compile_module_type_pattern
23from .rename_outcomes import OutcomeKind, RenameOutcome, renamed_count
24from .transactions import atomic_with_events
26logger = logging.getLogger(__name__)
28NO_RULE_REASON = "no rule matches the module after the change"
29FLAT_REASON = "a flat breakout family is not renamed after a move, a bay edit or a parent module type change"
30ELSEWHERE_REASON = "the interface is not on the device of its module"
31STALE_BAY_REASON = "the module bay still has the parent bay it had before its module moved"
34def pinned_rule_cache():
35 """Return the lower-level rule cache pinning context."""
36 return rule_selection.pinned_rule_cache()
39def find_matching_rule(module_type, parent_module_type, device_type, platform=None):
40 """Delegate rule selection while preserving the engine entry point."""
41 return rule_selection.find_matching_rule(module_type, parent_module_type, device_type, platform)
44def build_variables(module_bay, device=None):
45 """Delegate naming-variable construction while preserving the engine entry point."""
46 return naming.build_variables(module_bay, device=device)
49def evaluate_name_template(template: str, variables: dict) -> str:
50 """Delegate template evaluation while preserving the engine entry point."""
51 return name_template.evaluate_name_template(template, variables)
54def _get_parent_module_type(module_bay):
55 """Return the module type of the module installed in the parent bay, or None.
57 Used by ``apply_interface_name_rules`` to scope rules to a specific parent
58 module type (e.g., SFP inside a CVR-X2-SFP converter).
59 """
60 if module_bay.parent:
61 parent_bay = module_bay.parent
62 if hasattr(parent_bay, "installed_module") and parent_bay.installed_module:
63 return parent_bay.installed_module.module_type
64 return None
67def _selected_rule(module, module_bay):
68 """Return the rule that *module* in *module_bay* selects now, or None."""
69 device_type = module.device.device_type if module.device else None
70 platform = module.device.platform if module.device else None
71 return find_matching_rule(module.module_type, _get_parent_module_type(module_bay), device_type, platform)
74def supports_channelization():
75 """Delegate the channelization capability check while preserving the engine entry point."""
76 return family_ops.supports_channelization()
79def _vc_position_re():
80 """Delegate virtual-chassis token detection to template-name resolution."""
81 return family_template_names.vc_position_re()
84def supports_vc_position_token():
85 """Return True when this NetBox resolves ``{vc_position}`` in component template names (4.6+).
87 Probed from the constant that carries the token rather than a version comparison, so a backport
88 or an upstream removal is detected by what NetBox actually provides.
89 """
90 return _vc_position_re() is not None
93def _touches_a_family(plan) -> bool:
94 """Return whether *plan* acts on a family rather than one standalone interface."""
95 if isinstance(plan, family_ops.InstalledFamilyPlan):
96 return plan.parent_pk is not None or len(plan.members) > 1
97 return True
100def _run_scope(force_reapply, previous_forms):
101 """Return the scope of an automatic run; after a move the claim decides alone, without one."""
102 if previous_forms is not None:
103 return None
104 return family_ops.RunScope.FORCED if force_reapply else family_ops.RunScope.INSTALL
107_MEMBER_OUTCOME_KINDS = {
108 family_ops.FamilyStatus.CHANGED: OutcomeKind.RENAMED,
109 family_ops.FamilyStatus.BLOCKED: OutcomeKind.BLOCKED,
110 family_ops.FamilyStatus.STALE: OutcomeKind.BLOCKED,
111 family_ops.FamilyStatus.UNSUPPORTED: OutcomeKind.BLOCKED,
112 family_ops.FamilyStatus.FAILED: OutcomeKind.FAILED,
113}
116def _rename_outcome(member) -> RenameOutcome:
117 """Return the outcome fact of one executed family member."""
118 kind = _MEMBER_OUTCOME_KINDS[member.status]
119 if kind == OutcomeKind.BLOCKED and member.reason == family_ops.UNCLAIMED_BASE_REASON:
120 kind = OutcomeKind.UNCLAIMED
121 return RenameOutcome(kind, member.current_name, member.reason, member.target_name)
124def _rename_outcomes(family_outcomes) -> tuple[RenameOutcome, ...]:
125 """Return the outcome facts of executed families; a member that kept its correct name has none."""
126 return tuple(
127 _rename_outcome(member)
128 for family in family_outcomes
129 for member in family.members
130 if member.status != family_ops.FamilyStatus.UNCHANGED
131 )
134def _unresolved_outcomes(missing, interface_names) -> tuple[RenameOutcome, ...]:
135 """Return one unresolved-variable fact per interface that keeps its name for lack of *missing*."""
136 reason = name_template.unavailable_reason(missing)
137 return tuple(RenameOutcome(OutcomeKind.UNRESOLVED_VARIABLE, name, reason) for name in interface_names)
140class NamingPoint(NamedTuple):
141 """A point in the transaction at which NetBox gave a module's templates raw names.
143 ``naming`` is the ``ModuleNaming`` that holds the bay chain of the module then, or None for the
144 committed module; ``vc_position`` is its device's virtual-chassis position then, and ``move`` is
145 set when a move gave the names, which NetBox resolves with the resolver of its move planner.
146 """
148 naming: "ModuleNaming | None"
149 vc_position: int | None
150 move: bool
153def _raw_names_at(module, naming_points):
154 """Return, by template, the raw names that NetBox gave *module*'s templates at *naming_points*."""
155 names = defaultdict(set)
156 for point in naming_points:
157 templates = family_ops.resolved_template_names(module) if point.naming is None else point.naming.templates
158 for template in templates:
159 names[template.pk].add(template.at_chassis_position(point.vc_position, move=point.move).resolved)
160 return names
163def apply_interface_name_rules(module, module_bay, force_reapply=False):
164 """Apply InterfaceNameRule rename after module installation.
166 Looks up a matching rule for (module_type, parent_module_type, device_type, platform)
167 and renames interfaces created by NetBox's template instantiation.
169 Only processes interfaces whose name still matches the raw bay position
170 (i.e., haven't been renamed yet), ensuring idempotency. Pass
171 ``force_reapply=True`` to skip this check and re-apply rules to ALL
172 module interfaces (used when vc_position or other variables change).
174 Every rename and every creation goes through the family package, so this path builds and names
175 exactly what retroactive apply and the preview describe.
177 Returns:
178 Number of interfaces renamed/created, or 0 if no rule matched.
180 """
181 return renamed_count(module_rule_outcomes(module, module_bay, force_reapply))
184def module_rule_outcomes(
185 module, module_bay, force_reapply=False, report_only=False, naming=None, naming_points=()
186) -> Iterator[RenameOutcome]:
187 """Apply the module's rule as ``apply_interface_name_rules`` does, and yield its outcome facts.
189 Each family's facts are yielded before the next family runs, so a caller keeps them when a later
190 family fails. With *report_only*, nothing is renamed: only a rule that needs a variable the
191 device lacks gives facts.
193 *naming* is the module's ``ModuleNaming`` read before a move, a bay edit or a type change of its
194 parent module. The rule then renames every name one template claims through its current or
195 previous forms, and reports every other interface. Without a rule now, each interface the previous
196 rule named keeps its name and is reported. When the previous rule is a flat breakout rule, nothing
197 on the module is renamed and every interface is reported: NetBox keeps no link to a family, so it
198 could be recognised by name only (ADR 0015). When an interface of the module is on another device,
199 or the module's bay has a parent bay that does not hold the module that owns the bay, nothing is
200 renamed and every interface is reported.
202 *naming_points* are the ``NamingPoint`` values at which NetBox gave the module's templates raw
203 names in the transaction. The claim recognises the names of each point too.
204 """
205 rule = _selected_rule(module, module_bay)
206 previous_forms = None if naming is None else naming.previous_forms()
208 if (
209 previous_forms is not None
210 and previous_forms.rule is not None
211 and family_ops.builds_flat_family(previous_forms.rule)
212 ):
213 yield from _kept_flat_family(module)
214 return
215 if not rule:
216 if previous_forms is not None and previous_forms.rule is not None:
217 yield from _left_without_a_rule(module, previous_forms)
218 return
219 # One pin for the module: the claim and the family planner resolve its templates once.
220 with family_ops.pinned_template_cache():
221 yield from _apply_rule_to_module(
222 rule, module, module_bay, force_reapply, report_only, previous_forms, naming_points
223 )
226def _kept_flat_family(module) -> Iterator[RenameOutcome]:
227 """Yield a blocked fact for every interface of *module*, which a flat breakout rule before the save leaves alone."""
228 from dcim.models import Interface
230 for name in Interface.objects.filter(module_id=module.pk).order_by("pk").values_list("name", flat=True):
231 yield RenameOutcome(OutcomeKind.BLOCKED, name, FLAT_REASON)
234def _left_without_a_rule(module, previous_forms) -> Iterator[RenameOutcome]:
235 """Yield a blocked fact for each interface the previous rule named, now that no rule matches the module."""
236 from dcim.models import Interface
238 interfaces = list(Interface.objects.filter(module_id=module.pk).order_by("pk"))
239 for name in family_ops.names_the_previous_rule_gave(previous_forms, interfaces):
240 yield RenameOutcome(OutcomeKind.BLOCKED, name, NO_RULE_REASON)
243def _has_stale_parent_bay(module_bay) -> bool:
244 """Return whether *module_bay* belongs to a module but its parent bay does not hold that module.
246 NetBox sets a bay's parent to the bay of the module that owns it; rule selection read both already.
247 """
248 if module_bay.module_id is None:
249 return False
250 installed = getattr(module_bay.parent, "installed_module", None)
251 return installed is None or installed.pk != module_bay.module_id
254def _acted_on_names(rule, plans):
255 """Return the name of every interface the admitted *plans* act on, in plan order."""
256 names = []
257 for plan in plans:
258 if isinstance(plan, family_ops.InstalledFamilyPlan):
259 keeps_parent = plan.parent_pk is not None and not family_ops.names_installed_parent(rule)
260 names.extend(
261 member.snapshot.name
262 for member in plan.members
263 if not (keeps_parent and member.role == family_ops.MemberRole.PARENT)
264 )
265 continue
266 names.append(plan.base.name)
267 if isinstance(plan, family_ops.FlatCreationPlan):
268 names.extend(member.name for member in plan.members)
269 return tuple(dict.fromkeys(names))
272def _apply_rule_to_module(
273 rule, module, module_bay, force_reapply, report_only=False, previous_forms=None, naming_points=()
274):
275 """Plan and execute every family *rule* intends on *module*; see ``module_rule_outcomes``."""
276 from dcim.models import Interface
278 variables = build_variables(module_bay, device=module.device)
279 missing = name_template.unavailable_variables(variables, rule.name_template, rule.parent_name_template)
280 if report_only and not missing:
281 return
282 interfaces = list(Interface.objects.filter(module_id=module.pk).order_by("pk"))
283 # NetBox before 4.7 moves only the module row: its interfaces and nested bays keep the old placement.
284 if any(interface.device_id != module.device_id for interface in interfaces):
285 yield from (RenameOutcome(OutcomeKind.BLOCKED, i.name, ELSEWHERE_REASON) for i in interfaces)
286 return
287 if _has_stale_parent_bay(module_bay):
288 yield from (RenameOutcome(OutcomeKind.BLOCKED, i.name, STALE_BAY_REASON) for i in interfaces)
289 return
290 bases = family_ops.module_raw_bases(
291 module, rule, variables, interfaces, previous_forms, earlier_raw_names=_raw_names_at(module, naming_points)
292 )
293 planned = family_ops.plan_module_families(
294 module, rule, variables, interfaces, bases, _run_scope(force_reapply, previous_forms)
295 )
296 installed, leftover = planned.installed, planned.leftover
297 plans = [*installed, *leftover]
299 if missing:
300 yield from _unresolved_outcomes(missing, _acted_on_names(rule, plans))
301 return
303 any_outcome = False
304 for family in family_ops.execute_module_families(plans):
305 for outcome in _rename_outcomes((family,)):
306 any_outcome = True
307 yield outcome
308 families_seen = bool(installed) or any(_touches_a_family(plan) for plan in leftover)
310 if not force_reapply and previous_forms is None and leftover and not any_outcome and not families_seen:
311 # Nothing renamed, skipped or built as a family: NetBox may already give these names.
312 _flag_rule_potentially_deprecated(rule)
315def predict_rule_output(module, module_bay, raw_names):
316 """Predict the names apply_interface_name_rules would produce for raw_names.
318 Read-only: saves and mutates nothing. Used by external integrations (e.g.
319 netbox-librenms-plugin) that need to know the post-rename names without applying any rule.
321 The names are planned by the family module from the module type's templates, so prediction
322 describes the same families installed execution builds: a breakout rule expands one plain name
323 into the family it creates, a name the templates describe as part of a channelized family
324 follows that family instead, and a family the apply path refuses to touch predicts unchanged.
325 Returns raw_names unchanged when no rule matches.
327 Precondition: *raw_names* are resolved by the caller at call time. A name captured before the
328 device's virtual-chassis position changed is predicted from itself, not corrected to the name
329 the templates resolve to now — this function maps the names it is given.
330 """
331 rule = _selected_rule(module, module_bay)
332 if not rule:
333 return list(raw_names)
335 variables = build_variables(module_bay, device=module.device)
336 described = family_ops.describe_module_interfaces(module, raw_names)
337 channel_names = {interface.name for interface in described if interface.channel_id is not None}
338 base_names = [name for name in raw_names if name not in channel_names]
339 plan_set = family_ops.plan_prospective_families(
340 module, rule, variables, described, family_ops.given_raw_names(module, rule, variables, base_names)
341 )
342 return [name for raw_name in raw_names for name in plan_set.predicted_names(raw_name)]
345def reapply_module_rules(device):
346 """Re-apply module rules to every module on *device* after its virtual-chassis position changed.
348 The whole device is one batch: its modules match against one enabled-rule snapshot, and one
349 module type's interface templates are read once however many modules carry it. An unexpected
350 failure stops the module batch and propagates to the deferred callback boundary.
352 Returns the number of interfaces renamed across the device's modules.
353 """
354 return renamed_count(device_module_rule_outcomes(device))
357def device_module_rule_outcomes(
358 device, report_only=False, excluded_pks=(), naming_points=()
359) -> Iterator[RenameOutcome]:
360 """Reapply the rules of every module on *device* as ``reapply_module_rules`` does, and yield the outcome facts.
362 Each module's facts are yielded before the next module runs, so a caller keeps them when a later
363 module fails. *report_only* is passed to ``module_rule_outcomes``. The modules whose primary keys
364 are in *excluded_pks* are left out. *naming_points* are passed to ``module_rule_outcomes``.
365 """
366 from dcim.models import Module
368 modules = list(
369 Module.objects.filter(device=device)
370 .exclude(pk__in=excluded_pks)
371 .select_related(
372 "module_type",
373 "device__device_type",
374 "device__platform",
375 *family_template_names.BAY_CHAIN_RELATIONS,
376 )
377 )
378 with pinned_rule_cache(), family_ops.pinned_template_cache(modules):
379 for module in modules:
380 yield from module_rule_outcomes(
381 module,
382 module.module_bay,
383 force_reapply=True,
384 report_only=report_only,
385 naming_points=naming_points,
386 )
389_NAMING_RELATIONS = (
390 "module_type",
391 "device__device_type",
392 "device__platform",
393 "module_bay__parent__installed_module__module_type",
394 *family_template_names.BAY_CHAIN_RELATIONS,
395)
398@dataclass(frozen=True, eq=False)
399class ModuleNaming:
400 """What named one module's interfaces at the time it was read: a move, a bay edit or a type change reads it.
402 The module type and the scope select the rule that state gave the module. The template variables
403 and the templates as they resolved then rebuild the names that rule gave. The variables come from
404 ``bay_chain`` and ``vc_position``, the virtual-chassis position of the device then, and
405 ``previous_forms`` resolves the raw template names at ``vc_position`` too. ``device_pk`` is that
406 device. ``bay_values`` are the ``naming.bay_naming_values`` of the module's bay then.
407 ``raw_only`` is set when no rule has named the module's interfaces yet, because the module was
408 installed in the same transaction: they carry raw template names only.
409 """
411 module_pk: int
412 device_pk: int
413 module_type: object
414 parent_module_type: object | None
415 device_type: object | None
416 platform: object | None
417 bay_chain: naming.BayChain
418 vc_position: int | None
419 templates: tuple
420 bay_values: tuple
421 raw_only: bool = False
423 @classmethod
424 def of(cls, module):
425 """Return the naming of *module*, which carries ``_NAMING_RELATIONS``, as it is now."""
426 device = module.device
427 module_bay = module.module_bay
428 return cls(
429 module_pk=module.pk,
430 device_pk=device.pk,
431 module_type=module.module_type,
432 parent_module_type=_get_parent_module_type(module_bay),
433 device_type=device.device_type,
434 platform=device.platform,
435 bay_chain=naming.bay_chain(module_bay),
436 vc_position=naming.chassis_position(device),
437 templates=family_ops.resolved_template_names(module),
438 bay_values=naming.bay_naming_values(module_bay.position, module_bay.name),
439 )
441 @property
442 def variables(self):
443 """Return the template variables of this naming."""
444 return naming.bay_chain_variables(self.bay_chain, self.vc_position)
446 def at_chassis_position(self, vc_position):
447 """Return this naming at the virtual-chassis position *vc_position*; None means off a chassis."""
448 return replace(self, vc_position=vc_position)
450 def rule(self):
451 """Return the rule that the module type and the scope of this naming select now, or None."""
452 return find_matching_rule(self.module_type, self.parent_module_type, self.device_type, self.platform)
454 def selects_another_rule(self, module) -> bool:
455 """Return whether *module* now selects another rule than this naming; it reads the rule cache.
457 A module that ``committed_modules`` returned needs no other query.
458 """
459 return _selected_rule(module, module.module_bay) != self.rule()
461 def previous_forms(self) -> family_ops.PreviousForms:
462 """Return what rebuilds the names this naming gave, under the rule it selects now; no rule when ``raw_only``."""
463 rule = None if self.raw_only else self.rule()
464 templates = {template.pk: template.at_chassis_position(self.vc_position) for template in self.templates}
465 return family_ops.PreviousForms(rule, self.variables, templates)
468def read_subtree_naming(module_pk) -> tuple[ModuleNaming, ...]:
469 """Return the naming of the module and of every module nested in it, the module itself first.
471 A move reads this before its save: in the same save NetBox can re-resolve the position and name
472 of each bay the module holds, so the nested modules' naming is gone afterwards. A bay edit reads
473 it before its save, because the save changes what the names in the bay are built from. A type
474 change reads it before its save, because the nested modules can then select another rule.
475 """
476 from dcim.models import Module
478 modules = []
479 level = list(Module.objects.filter(pk=module_pk).select_related(*_NAMING_RELATIONS))
480 while level:
481 modules.extend(level)
482 level = list(
483 Module.objects.filter(module_bay__module__in=level)
484 .exclude(pk__in=[module.pk for module in modules])
485 .select_related(*_NAMING_RELATIONS)
486 .order_by("pk")
487 )
488 with family_ops.pinned_template_cache(modules):
489 return tuple(ModuleNaming.of(module) for module in modules)
492def committed_modules(module_pks) -> dict:
493 """Return the committed modules that *module_pks* name, by primary key, with every relation a reapply reads."""
494 from dcim.models import Module
496 return Module.objects.select_related(*_NAMING_RELATIONS).in_bulk(module_pks)
499@contextlib.contextmanager
500def pinned_reapply(modules):
501 """Share one enabled-rule snapshot, and the resolved templates of *modules*, across one reapply."""
502 with pinned_rule_cache(), family_ops.pinned_template_cache(modules):
503 yield
506def _device_interface_rules(device):
507 """Return enabled device-interface rules in matching priority order."""
508 from django.db.models import Q
510 from .models import InterfaceNameRule
512 device_type = getattr(device, "device_type", None)
513 platform = getattr(device, "platform", None)
514 rules = list(
515 InterfaceNameRule.objects.filter(
516 applies_to_device_interfaces=True,
517 enabled=True,
518 )
519 .filter(Q(device_type=device_type) | Q(device_type__isnull=True))
520 .filter(Q(platform=platform) | Q(platform__isnull=True))
521 )
522 # Sort Python-side: specificity_score descending, then module_type_pattern length
523 # descending (for device-interface rules with ties), then pk ascending for stability.
524 # (InterfaceNameRule has no DB 'priority' field; specificity_score is a property.)
525 rules.sort(
526 key=lambda r: (
527 -r.specificity_score,
528 -len(r.module_type_pattern or ""),
529 r.pk,
530 )
531 )
532 return rules
535def _matches_device_interface(rule, interface):
536 """Return whether one device-interface rule matches one device interface."""
537 if not rule.module_type_pattern:
538 return True
539 try:
540 compiled = compile_module_type_pattern(rule.module_type_pattern)
541 except ValidationError:
542 return False
543 return compiled.fullmatch(interface.name) is not None
546def _apply_device_rule_to_families(device, vc_position, rule, families, claimed_pks, report_only, by_family):
547 """Apply one rule to each eligible device-interface family and record its outcome facts in *by_family*.
549 A rule that renames a family, or finds it correct, replaces the skips earlier rules left on it;
550 a failure stays reported.
551 """
552 for interface, children in families:
553 if interface.pk in claimed_pks or not _matches_device_interface(rule, interface):
554 continue
555 variables = naming.build_device_interface_variables(interface.name, vc_position)
556 missing = name_template.unavailable_variables(variables, rule.name_template)
557 if missing:
558 names = (interface.name, *(child.name for child in children))
559 by_family.setdefault(interface.pk, []).extend(_unresolved_outcomes(missing, names))
560 claimed_pks.update((interface.pk, *(child.pk for child in children)))
561 continue
562 if report_only:
563 claimed_pks.update((interface.pk, *(child.pk for child in children)))
564 continue
565 plan = family_ops.plan_device_interface_rename(device, rule, variables, interface, children)
566 outcome = family_ops.execute_installed_plan(plan)
567 if outcome.status in {family_ops.FamilyStatus.CHANGED, family_ops.FamilyStatus.UNCHANGED}:
568 claimed_pks.update(plan.member_pks)
569 failures = [earlier for earlier in by_family.get(interface.pk, ()) if earlier.kind == OutcomeKind.FAILED]
570 by_family[interface.pk] = [*failures, *_rename_outcomes((outcome,))]
571 else:
572 by_family.setdefault(interface.pk, []).extend(_rename_outcomes((outcome,)))
575def apply_device_interface_rules(device):
576 """Rename device-level interfaces (module=None) when a device joins/changes position in a VC.
578 Finds all enabled rules with ``applies_to_device_interfaces=True`` that match the device's
579 type and platform, then renames any matching interfaces using the name_template.
581 Template variables available: ``{vc_position}``, ``{base}`` (full current name),
582 ``{port}`` (segment after the last ``/``, or the full name if no ``/`` present).
584 Channel subinterfaces are not matched independently. They follow the parent whose family
585 a rule wins, so a template like ``eth{vc_position}`` cannot collapse a whole family onto
586 one name.
588 Returns the number of interfaces renamed.
589 """
590 if not getattr(device, "virtual_chassis_id", None):
591 return 0 # Only rename for VC members (vc_position must be set)
593 if device.vc_position is None:
594 return 0 # vc_position unset (e.g. VC master before position assigned)
596 return renamed_count(device_interface_rule_outcomes(device))
599def device_interface_rule_outcomes(device, report_only=False) -> Iterator[RenameOutcome]:
600 """Apply the device-interface rules and yield the outcome facts.
602 Unlike ``apply_device_interface_rules``, this also runs for a device without a virtual-chassis
603 position: a rule that reads ``{vc_position}`` then renames nothing and reports each interface it
604 matches as unresolved. With *report_only*, no rule renames anything.
606 The facts are yielded after the last rule, because a later rule can replace a family's facts.
607 When a rule fails, the facts recorded before the failure are yielded before the error propagates.
608 """
609 from dcim.models import Interface
611 vc_position = naming.chassis_position(device)
612 rules = _device_interface_rules(device)
613 if not rules:
614 return
616 interfaces = list(Interface.objects.filter(device=device, module=None).order_by("pk"))
617 if not interfaces:
618 return
620 families = family_ops.device_interface_families(interfaces)
621 claimed_pks: set[int] = set()
622 by_family: dict[int, list] = {}
623 try:
624 for rule in rules:
625 _apply_device_rule_to_families(device, vc_position, rule, families, claimed_pks, report_only, by_family)
626 finally:
627 # A caller that extends a list keeps these facts when a later family raises.
628 for outcomes in by_family.values():
629 yield from outcomes
632def _flag_rule_potentially_deprecated(rule):
633 """Tag a rule as 'potentially-deprecated' when its rename is a no-op.
635 Called from apply_interface_name_rules when a matching rule produces no
636 renames because NetBox already generates the correct interface names. This
637 may indicate the rule is no longer needed (e.g. after a NetBox upgrade that
638 improved template resolution), or only needed for a subset of module types.
640 Adds a NetBox Tag 'potentially-deprecated' so the rule is visually flagged
641 in the UI for operator review. A database failure is logged but never
642 re-raised so the install path is not disrupted.
643 """
644 from extras.models import Tag
646 from .models import InterfaceNameRule
648 try:
649 with atomic_with_events():
650 # The caller read the rule before the reapply, so the stored row can be newer.
651 stored = InterfaceNameRule.objects.select_for_update().filter(pk=rule.pk).order_by("pk").first()
652 if stored is None:
653 return
654 tag, _ = Tag.objects.get_or_create(
655 slug="potentially-deprecated",
656 defaults={"name": "potentially-deprecated", "color": "ffc107"},
657 )
658 stored.snapshot()
659 stored.tags.add(tag)
660 except DatabaseError:
661 logger.exception("Failed to flag rule '%s' as potentially-deprecated.", rule)
662 return
663 logger.info(
664 "Rule '%s' flagged as potentially-deprecated: NetBox already generates the correct interface names.",
665 stored,
666 )
669def _matching_moduletype_pks(module_type_pattern):
670 """Return PKs of ModuleTypes whose model name matches the given RE2 pattern.
672 Raises ValueError for invalid patterns, mirroring evaluate_name_template's
673 error-handling convention so callers can treat both as ValueError.
674 """
675 from dcim.models import ModuleType
677 try:
678 compiled = compile_module_type_pattern(module_type_pattern)
679 except ValidationError as exc:
680 raise ValueError(exc.messages[0]) from exc
681 return [mt.pk for mt in ModuleType.objects.only("pk", "model") if compiled.fullmatch(mt.model)]
684def has_applicable_interfaces(rule) -> bool:
685 """Check whether applying this rule right now would rename at least one interface.
687 Calls find_interfaces_for_rule(limit=1) to determine if any currently installed
688 interface would receive a new name. Returns False when:
689 - no matching modules/interfaces are installed, OR
690 - all matching interfaces are already correctly named.
692 This is more expensive than a plain EXISTS query but ensures the Applicable
693 column in the Apply Rules list accurately reflects "would something change?"
694 rather than the misleading "do interfaces exist?".
695 """
696 try:
697 results, _ = find_interfaces_for_rule(rule, limit=1)
698 return len(results) > 0
699 except ValueError:
700 return False
703def _build_module_qs(rule):
704 """Return a Module queryset filtered to the rule's scope (module type, parent, device, platform).
706 Shared by ``find_interfaces_for_rule`` and ``apply_rule_to_existing`` to avoid
707 duplicating the filtering logic.
708 """
709 from dcim.models import Module
711 if rule.module_type_is_regex:
712 qs = Module.objects.filter(module_type__in=_matching_moduletype_pks(rule.module_type_pattern))
713 else:
714 qs = Module.objects.filter(module_type=rule.module_type)
715 if rule.parent_module_type:
716 qs = qs.filter(module_bay__parent__installed_module__module_type=rule.parent_module_type)
717 if rule.device_type:
718 qs = qs.filter(device__device_type=rule.device_type)
719 if rule.platform:
720 qs = qs.filter(device__platform=rule.platform)
721 return qs
724_PREVIEW_ROLES = {
725 family_ops.MemberRole.PARENT: "parent",
726 family_ops.MemberRole.CHANNEL: "channel",
727}
730def _member_detail(plan, member) -> family_ops.PlannedName:
731 """Describe one planned member so the UI can render a family as a family.
733 A flat family's members are the channels a breakout rule spells out; a plan that holds only
734 one of them is a plain rename, not a family.
735 """
736 role = _PREVIEW_ROLES.get(member.role) or ("channel" if len(plan.members) > 1 else "interface")
737 return family_ops.PlannedName(member.target_name, role, member.channel_id)
740def _plan_details(plan) -> list:
741 """Describe every name the plan intends, or the error that stopped it from naming them."""
742 if plan.precondition_status != family_ops.FamilyStatus.FAILED:
743 return [_member_detail(plan, member) for member in plan.members]
744 root = _member_detail(plan, plan.members[0])
745 return [family_ops.PlannedName(f"<error: {plan.precondition_reason}>", root.role, root.channel_id)]
748def _plan_changes_names(plan, existing_names) -> bool:
749 """Return whether the planned family would rename, create or restructure anything.
751 A plan that renames members compares intent with the names they carry now. A plan that builds
752 a channelized family always channelizes its base, also when it only takes over rows that carry
753 the names already (ADR 0017). A plan that builds a flat family compares intent with the names
754 the module already holds, so a family an earlier apply already installed previews as no change.
755 """
756 if plan.base_name is None: # pragma: no cover - requires channelization support
757 return plan.target_names != plan.source_names
758 if plan.topology == family_ops.FamilyTopology.CHANNELIZED:
759 return True
760 return plan.target_names[0] != plan.base_name or any(
761 target_name not in existing_names for target_name in plan.target_names
762 )
765def _plan_entry(module, plan, interface, existing_names) -> dict | None:
766 """Build the preview entry for one family plan, or None when it would change nothing.
768 The entry stays keyed on the interface the Apply view submits, and lists the family's names in
769 ``new_names``, so the existing template loop keeps working unchanged. A plan the live topology
770 blocks previews nothing, because the apply path would build nothing either.
771 """
772 failed = plan.precondition_status == family_ops.FamilyStatus.FAILED
773 if plan.precondition_status is not None and not failed:
774 return None
775 if not failed and not _plan_changes_names(plan, existing_names):
776 return None
777 details = _plan_details(plan)
778 return {
779 "module": module,
780 "interface": interface,
781 "current_name": interface.name,
782 "new_names": [detail.name for detail in details],
783 "name_details": details,
784 }
787def _plan_root_name(plan) -> str:
788 """Return the name of the interface a plan is submitted through."""
789 return plan.base_name if plan.base_name is not None else plan.members[0].source_name
792def _preview_plans(rule, plan_set) -> list:
793 """Return the plans this preview reports.
795 A breakout rule on a module that already models channelized families renames those families and
796 adds none beside them. Anywhere else it builds one family per base, and two bases that intend
797 the same names are the one family an earlier apply already started, so it is offered once: the
798 same family the apply path would build.
799 """
800 if rule.channel_count <= 0:
801 return list(plan_set.plans)
802 installed = [plan for plan in plan_set.plans if plan.base_name is None]
803 if installed: # pragma: no cover - requires a NetBox that models channelization
804 return installed
805 creations = [plan for plan in plan_set.plans if plan.base_name is not None]
806 kept = family_ops.one_family_per_name_set([(plan.base_name, plan.target_names) for plan in creations])
807 return [creations[index] for index in kept]
810def _installed_flat_entry(module, plan, interface) -> dict | None:
811 """Build the preview entry for an installed flat family, or None when it keeps its names."""
812 if all(member.target_name == member.snapshot.name for member in plan.members):
813 return None
814 role = "channel" if len(plan.members) > 1 else "interface"
815 details = [family_ops.PlannedName(member.target_name, role) for member in plan.members]
816 return {
817 "module": module,
818 "interface": interface,
819 "current_name": interface.name,
820 "new_names": [detail.name for detail in details],
821 "name_details": details,
822 }
825def _process_module(rule, module, ifaces, variables, limit, results, module_qs, processed_pks):
826 """Preview one module from its family plans. Returns (checked_count, should_stop).
828 An installed flat family is previewed from the plan Apply Rules executes, because its members
829 carry names no template describes.
830 """
831 bases = family_ops.module_raw_bases(module, rule, variables, ifaces)
832 installed = family_ops.plan_installed_flat_families(module, rule, variables, ifaces, bases)
833 installed_pks = {pk for plan in installed for pk in plan.member_pks}
834 rows = [iface for iface in ifaces if iface.pk not in installed_pks]
835 plan_set = family_ops.plan_prospective_families(
836 module, rule, variables, family_ops.describe_interfaces(rows), bases
837 )
838 # A flat family counts its members, as the scan of unvisited modules counts interfaces.
839 checked = sum(len(plan.members) for plan in installed) + len(plan_set.plans)
840 if not checked:
841 return 0, False
842 rows_by_pk = {iface.pk: iface for iface in ifaces}
843 rows_by_name = {iface.name: iface for iface in ifaces}
844 existing_names = frozenset(rows_by_name)
845 entries = [_installed_flat_entry(module, plan, rows_by_pk[plan.member_pks[0]]) for plan in installed]
846 offered = _preview_plans(rule, plan_set)
847 # The apply refuses a family whose names are in use, so the preview offers no change there.
848 refused = family_ops.creation_name_refusals(module, rule, ifaces, bases, offered)
849 entries.extend(
850 _plan_entry(module, plan, rows_by_name[_plan_root_name(plan)], existing_names)
851 for plan in offered
852 if plan.base_name not in refused
853 )
854 for entry in entries:
855 if entry is None:
856 continue
857 results.append(entry)
858 if limit is not None and len(results) >= limit:
859 return checked + _count_remaining_interfaces(module_qs, processed_pks), True
860 return checked, False
863def _count_remaining_interfaces(module_qs, processed_pks) -> int:
864 """Count the rule candidates in modules not yet visited during a find_interfaces_for_rule scan."""
865 from dcim.models import Interface
867 qs = Interface.objects.filter(module__in=module_qs.exclude(pk__in=processed_pks))
868 if supports_channelization(): # pragma: no cover - the column exists only on NetBox 4.7+
869 qs = qs.filter(channel_id__isnull=True) # a family counts once, through its parent
870 return qs.count()
873def find_interfaces_for_rule(rule, limit=None):
874 """Find interfaces that would be renamed by applying the given rule retroactively.
876 Searches for all Module instances matching the rule's criteria and computes
877 what their interfaces would be renamed to.
879 Returns a tuple ``(results, total_checked)`` where *results* is a list of dicts::
881 {
882 "module": Module instance,
883 "interface": Interface instance,
884 "current_name": str,
885 "new_names": list[str], # one entry per channel, or single-element
886 "name_details": list[PlannedName], # name, role and channel id per new_names entry
887 }
889 Only includes entries where at least one new_name differs from current_name.
890 A channelized parent is reported once, with its channels' names in the same entry, so the
891 caller can act on the family through the parent PK it already submits. If *limit* is set the
892 list is truncated after that many changed entries, but *total_checked* always reflects the full
893 count of families examined (a family counts once, however many channels it has).
894 """
895 from dcim.models import Interface
897 module_qs = _build_module_qs(rule).select_related(
898 "module_type",
899 "device__device_type",
900 "device__platform",
901 *family_template_names.BAY_CHAIN_RELATIONS,
902 )
903 # Batch-load all interfaces for matching modules to avoid N+1 queries.
904 ifaces_by_module = defaultdict(list)
905 for iface in Interface.objects.filter(module__in=module_qs).order_by("module_id", "name"):
906 ifaces_by_module[iface.module_id].append(iface)
908 modules = list(module_qs)
909 processed_pks = set()
910 results = []
911 total_checked = 0
912 with family_ops.pinned_template_cache(modules):
913 for module in modules:
914 processed_pks.add(module.pk)
915 variables = build_variables(module.module_bay, device=module.device)
916 ifaces = ifaces_by_module.get(module.pk, [])
917 checked, stop = _process_module(rule, module, ifaces, variables, limit, results, module_qs, processed_pks)
918 total_checked += checked
919 if stop:
920 return results, total_checked
922 return results, total_checked
925def _batch_modules(rule):
926 """Return the rule's modules with every relation planning and template resolution dereference."""
927 return list(
928 _build_module_qs(rule).select_related(
929 "module_type",
930 "device__device_type",
931 "device__platform",
932 *family_template_names.BAY_CHAIN_RELATIONS,
933 )
934 )
937def apply_rule_to_existing(rule, limit=None, interface_ids=None) -> family_ops.BatchOutcome:
938 """Apply a rule retroactively to all matching installed modules.
940 Unlike apply_interface_name_rules(), this does not skip already-renamed interfaces: it plans
941 every family each matching module carries or would gain, and executes each in its own
942 transaction, so one blocked family costs the batch only that family.
944 If *interface_ids* is provided (list/set of Interface PKs), only the families those interfaces
945 reach are applied; an empty collection touches the database not at all. Selecting a
946 channelized parent brings its channel subinterfaces along; selecting a channel subinterface on
947 its own does nothing, because it is not an independent candidate. If *limit* is set the batch
948 stops after the module that reached that many changed interfaces.
950 Returns the batch outcome: one explicit family result per family it planned.
951 """
952 id_set = frozenset(interface_ids) if interface_ids is not None else None
953 if not rule.enabled or (id_set is not None and not id_set):
954 return family_ops.BatchOutcome(families=())
955 return family_ops.apply_rule_to_modules(rule, _batch_modules(rule), selected_pks=id_set, limit=limit)
958# ---------------------------------------------------------------------------
959# Assisted flat → channelized conversion
960# ---------------------------------------------------------------------------
961# An earlier flat apply leaves N sibling interfaces where NetBox 4.7+ models a channelized parent
962# with N channel subinterfaces. Converting one rewrites rows an operator owns — cables, addresses,
963# tags — so it is never a side effect of applying a rule: the operator confirms it per family.
966def find_convertible_families(rule, limit=None) -> family_ops.ConversionPreview:
967 """Return the preview of the flat families *rule* could convert, convertible or not.
969 Each candidate names the ch-0 row the confirm form submits, the family's current names, the
970 names it would carry, and where the ch-0 row's configuration lands. A family beyond *limit* is
971 never dry-run; the preview reports that one was left unexamined.
972 """
973 # Only the cheap half of the guard, so a rule that offers no conversion never reads its modules;
974 # whether this release can hold a family is the family package's call.
975 if not family_ops.conversion_offered(rule):
976 return family_ops.ConversionPreview(candidates=())
977 return family_ops.preview_rule_conversions(rule, _batch_modules(rule), limit=limit)
980def convert_flat_families(rule, base_pks=None) -> family_ops.BatchOutcome:
981 """Convert *rule*'s installed flat families to the channelized topology.
983 *base_pks* is the set of ch-0 interface pks the operator confirmed: ``None`` converts every
984 convertible family (the batch the background job runs), an empty collection converts none.
986 Returns the batch outcome: one explicit family result per family it planned.
987 """
988 selected = None if base_pks is None else frozenset(base_pks)
989 return family_ops.convert_rule_families(rule, _batch_modules(rule), selected_pks=selected)