Skip to content

Architecture

This document describes the internal architecture of the NetBox FMS plugin. It is intended for developers who want to understand, extend, or contribute to the codebase.

Data Model Hierarchy

NetBox FMS follows the same Type/Instance pattern used by NetBox's own DeviceType / Device relationship. A FiberCableType acts as a blueprint that defines cable construction and component templates. When a FiberCable instance is created, it reads those templates and auto-instantiates the corresponding physical components.

erDiagram
    FiberCableType ||--o{ BufferTubeTemplate : "has"
    FiberCableType ||--o{ RibbonTemplate : "has (central-core)"
    FiberCableType ||--o{ CableElementTemplate : "has"
    BufferTubeTemplate ||--o{ RibbonTemplate : "has (ribbon-in-tube)"

    FiberCableType ||--o{ FiberCable : "instantiates"
    FiberCable ||--|| Cable : "extends (dcim)"
    FiberCable ||--o{ BufferTube : "contains"
    FiberCable ||--o{ Ribbon : "contains (central-core)"
    FiberCable ||--o{ FiberStrand : "contains (tight buffer)"
    FiberCable ||--o{ CableElement : "contains"
    BufferTube ||--o{ Ribbon : "contains"
    BufferTube ||--o{ FiberStrand : "contains"
    Ribbon ||--o{ FiberStrand : "contains"

    SpliceProject ||--o{ SplicePlan : "groups"
    SplicePlan }o--|| Device : "for closure (dcim)"
    SplicePlan ||--o| User : "submitted_by"
    SplicePlan ||--o{ SplicePlanEntry : "contains"
    SplicePlanEntry ||--|| FrontPort : "fiber_a (dcim)"
    SplicePlanEntry ||--|| FrontPort : "fiber_b (dcim)"

    ClosureCableEntry ||--|| Device : "on (dcim)"
    ClosureCableEntry ||--|| FiberCable : "cable"

    FiberCircuit ||--o{ FiberCircuitPath : "has"
    FiberCircuitPath }o--|| FiberStrandPath : "assigns"
    FiberStrandPath ||--o{ FiberStrandPathHop : "has"
    FiberStrandPathHop }o--o| FiberStrand : "strand"
    FiberStrandPathHop }o--o| Cable : "cable (dcim)"
    FiberStrandPathHop }o--o| Circuit : "provider circuit (circuits)"
    FiberCircuit ||--o{ RouteChangeAuthorization : "authorized by"
    PathAnomaly }o--o| FiberStrand : "strand"
    PathAnalysisQueue }o--|| Device : "device (dcim)"

Model Groups

Models are organized into four functional groups:

1. Type-level (blueprints)

These models define the construction of a fiber cable without representing a physical instance.

Model Purpose
FiberCableType Top-level blueprint. Defines fiber count, jacket color, cable type, and owns component templates. Counter-cached instance count via connect_counters().
BufferTubeTemplate Template for a buffer tube within a cable type. Specifies fiber count or holds child RibbonTemplate entries.
RibbonTemplate Template for a fiber ribbon. Can be a child of a BufferTubeTemplate (ribbon-in-tube) or directly on a FiberCableType (central-core ribbon).
CableElementTemplate Template for non-fiber elements such as strength members or messenger wires.

2. Instance-level

Created automatically by FiberCable._instantiate_components() when a new FiberCable is saved. Four construction cases are supported:

Case Structure
Loose tube BufferTube with FiberStrand children
Ribbon-in-tube BufferTube -> Ribbon -> FiberStrand
Central-core ribbon Ribbon (on cable) -> FiberStrand
Tight buffer FiberStrand directly on cable
Model Purpose
FiberCable Instance linked one-to-one with a dcim.Cable. Triggers component instantiation on creation.
BufferTube Physical buffer tube within a cable.
Ribbon Fiber ribbon, either inside a tube or directly in a cable.
FiberStrand Individual fiber strand. Automatically assigned colors based on FiberCableType's color_scheme.
CableElement Non-fiber element instance (strength member, tracer wire, etc.).

3. Splice planning

Model Purpose
SpliceProject Groups related splice plans for project-level organization.
SplicePlan A splice plan for a specific closure (dcim.Device, ForeignKey -- multiple plans per closure allowed). Tracks diff staleness for cache invalidation. Status follows a four-state FSM: draft -> pending_approval -> approved -> archived. Stores submitted_by (FK to auth.User). Enforces fiber exclusivity: each FrontPort may appear in at most one approved plan at a time.
SplicePlanEntry Maps one dcim.FrontPort (fiber_a) to another dcim.FrontPort (fiber_b), representing a planned splice. Includes a change_note field for per-entry annotations.
ClosureCableEntry Records which FiberCable instances enter a closure (dcim.Device).
SlackLoop Tracks slack loop storage at a closure.

4. Fiber circuits

Model Purpose
FiberCircuit End-to-end logical circuit spanning multiple fiber segments and splices.
FiberCircuitPath A circuit's assignment of one analyzed FiberStrandPath: the assigned-hops snapshot, active, is_broken / broken_reason, loss fields.
FiberStrandPath One continuous fiber chain between two classified ends, derived by the analysis (internal model, read-only UI and API). Carries completeness, route_key, is_proposed and is_defective.
FiberStrandPathHop One ordered step of a path: a strand, a plain cable (no FiberCable) or a provider circuit.
PathAnomaly A plant shape the analysis refused to trace (too many connections, loop, dangling reference).
RouteChangeAuthorization Records that an approved change may re-route a circuit without breaking it; consumed by the analysis.
PathAnalysisQueue One row per plant change per touched device, waiting for the next analysis run.

Splice Plan Lifecycle

State Machine

A SplicePlan moves through four states:

draft  ->  pending_approval  ->  approved  ->  archived
  ^              |
  +--------------+  (rejection / reset to draft)
Transition Meaning
draft -> pending_approval Submitter requests review; submitted_by is recorded.
pending_approval -> approved Reviewer approves the plan; diff may now be applied.
pending_approval -> draft Reviewer rejects or submitter retracts; plan is editable again.
approved -> archived Plan has been applied and is no longer active.

Enforcement Points

State transition guards are checked in three places:

  1. SplicePlan.clean() -- Validates that the requested status transition is legal and that the fiber exclusivity invariant is satisfied before the model is saved.
  2. API views -- Dedicated action endpoints (/submit/, /approve/, /archive/) enforce that callers supply the correct current state before permitting a transition.
  3. Visual editor -- The splice editor widget disables mutation controls (add/remove/reorder entries) when the plan is not in the draft state.

Fiber Exclusivity Invariant

A given FrontPort may appear as fiber_a or fiber_b in at most one approved SplicePlan at a time. This constraint is enforced in SplicePlan.clean() before the plan transitions to approved. Plans in other states (draft, pending_approval, archived) do not participate in the exclusivity check, so the same port can be planned in multiple drafts simultaneously.

Service Layer

Business logic is separated from Django model methods into dedicated service modules.

services.py -- Splice plan computation and cable topology

  • compute_diff(plan) -- Compares the desired splice state (from SplicePlanEntry records) against the live state (actual PortMapping entries in NetBox) and returns a per-tray breakdown of additions, removals, and unchanged splices. Pairs touching device-level front ports (unassigned tubes) are grouped under the UNASSIGNED_TRAY_ID = 0 sentinel bucket instead of a tray.
  • get_live_state(closure) / get_desired_state(plan) -- Query helpers that feed compute_diff(). Both consider every front port of the closure.
  • apply_diff(plan) -- Applies the computed diff to NetBox's PortMapping table, creating and deleting mappings as needed. Refuses plans that are not in approved status and archives the plan after a successful apply, so every apply path honours the approval workflow.
  • import_live_state(plan) -- Imports the current live splice state into a plan's entries. Runs automatically when a plan is created through the web form or the API, so a new plan starts as a copy of the live state instead of an implicit delete-everything. The import is an idempotent sync: pairs the plan's own entries already cover are skipped, as are live pairs with no tray-mounted port (an entry requires a tray) and pairs on fibers claimed by another active plan; the returned report carries a count for each skip reason.
  • link_cable_topology(cable, fiber_cable_type, device, ...) -- Atomic transaction that creates a FiberCable, adopts or creates FrontPort/RearPort pairs on the device, and sets the cable profile.
  • propose_port_mapping() -- Builds a position-based mapping from strand positions to FrontPort instances for confirmation before linking.

path_graph.py -- Graph loader and walker

Bulk-loads the affected plant region (cable terminations, port mappings, strand landings, splice jumpers) into a networkx graph and walks it in memory from every front port to produce chains of hops with classified ends. The analysis jobs and the trace view share this one implementation.

path_analysis.py -- Result writer, reconcile and lock

Matches walked chains to stored FiberStrandPath rows, rewrites, creates or deletes them in bulk, records anomalies, evaluates assignments (broken, authorized, path_lost) and syncs FiberCircuit.is_broken. Also holds the PostgreSQL advisory lock that serializes analysis runs and the whole-plant reconcile.

path_queue.py -- Change queue

enqueue_devices(device_ids, reason) buffers per transaction and writes PathAnalysisQueue rows on commit; schedule_analysis() enqueues the Fiber path analysis job one window later unless one is already pending.

jobs.py -- Background jobs

PathAnalysisJob (incremental, per queued device) and PathReconcileJob (system job, whole plant, registered in ready() with the configured interval).

assignment.py -- Picker and assignment

find_assignable_path_groups() is the shared picker (groups of paths on one route, filtered and ranked); assign_paths() creates assignments; acknowledge_route() accepts new hops; authorize_route_change() writes a RouteChangeAuthorization. The *_for(user, ...) variants add the permission checks used by the UI and API.

trace.py and trace_hops.py -- Pairing rule and display hops

trace.py holds only the rule that pairs rear ports across a cable (by connector number) for the walker. trace_hops.py builds the display hops of the trace view from a stored path (flat_entries, build_hops).

export.py -- Draw.io diagram generation

  • generate_drawio(plan) -- Creates mxGraph XML for a splice plan. Each splice tray becomes a separate page/tab in the diagram. Fibers are colored using each strand's stored color (assigned from the cable type's color scheme), and diff annotations (add/remove/unchanged) are applied to splice connections.

constants.py -- Fiber color schemes

  • EIA_598_COLORS -- Tuple of 12 (hex_color, name) pairs following the EIA/TIA-598 standard fiber color code (Blue, Orange, Green, Brown, Slate, White, Red, Black, Yellow, Violet, Rose, Aqua).
  • NBR_14771_COLORS -- Tuple of 12 (hex_color, name) pairs following the ABNT NBR-14771 Brazilian standard (same colors, different position order).
  • DIN_IEC_60304_COLORS -- Tuple of 12 (hex_color, name) pairs following the German DIN VDE 0888 fiber sequence built on the IEC 60304 color set (Red, Green, Blue, Yellow, White, Grey, Brown, Violet, Turquoise, Black, Orange, Pink).
  • COLOR_SCHEME_PALETTES -- Dict mapping scheme choices to color tuples, enabling multi-standard support.
  • get_strand_color(position, scheme) -- Returns the color tuple for a 1-indexed fiber position under the given color scheme, cycling through the 12-color palette for positions beyond 12.

cable_profiles.py -- High-count fiber cable profiles

NetBox's built-in cable profiles cap at 16 positions (Single1C16P). Fiber plant cables commonly have far more strands. This module defines BaseCableProfile subclasses for 24, 48, 72, 96, 144, 216, 288, and 432 strand counts, allowing the trace algorithm to route at the individual strand level through high-count fiber trunk cables.

Signal Handlers

signals.py

Registered via connect_signals() in PluginConfig.ready().

  • cable_post_save -- On dcim.Cable save, checks whether the cable terminates on FrontPort instances belonging to a closure with a SplicePlan. If so, marks the plan's diff as stale (diff_stale=True) so it will be recomputed on next access. Also renames ports via _rename_ports_for_cable() when a cable is linked to a FiberCable.
  • cable_pre_delete -- Same invalidation logic, but on pre_delete (before cascade removes the termination records needed for the device lookup).
  • portmapping_pre_save / portmapping_pre_delete -- Block external changes to FMS-managed PortMapping rows, preventing out-of-band edits that would bypass splice plan enforcement. A mapping is FMS-managed when a FiberStrand has landed on its front port, when any mapping on its rear port serves such a strand, or when a non-archived SplicePlan splices its front port. Other port pairs on the same device stay user-editable, so a housing can mix FMS-managed cables with unmanaged ones. Internal operations that legitimately need to write PortMapping records must use the fms_portmapping_bypass context manager to suppress the guard.
  • fibercable_post_save -- Syncs port names on the associated closure device when a FiberCable is linked to a dcim.Cable.
  • closure_cable_entry_post_delete -- Cleans up orphaned TubeAssignment records when a ClosureCableEntry is removed.

Template Content Injection

template_content.py

Uses NetBox's PluginTemplateExtension system to inject content into core NetBox detail views.

  • CableFiberCablePanel -- Adds a panel to the dcim.Cable detail page showing the associated FiberCable instance, its type, and tube count. Only renders when a FiberCable is linked to the cable.

Plugin Registration

__init__.py

The NetBoxFMSConfig class (subclass of PluginConfig) serves as the plugin entry point.

Setting Value
base_url "fms"
min_version "4.5.0"

The ready() method performs two setup actions:

  1. connect_signals() -- Registers the post_save and pre_delete handlers for dcim.Cable described above.
  2. connect_counters(FiberCableType) -- Enables NetBox's built-in counter cache so that FiberCableType tracks how many FiberCable instances reference it without requiring a count query on every list view.