Fix rear-face image-only labels, click-to-add units, cable concept devices

- Fix "images only" leaving plain labels visible on faces where a
  device has no image for that side (e.g. no rear_image set): the
  view selector now hides every plain label outright in that mode,
  not just the ones an image happens to sit on top of.
- Remove the "Add planned device" button from the elevation card.
  Empty units are now click targets themselves - clicking one opens
  the add-device form pre-filled with that position and face, exactly
  like a real rack's elevation. Occupancy is computed per unit per
  face from the existing device blocks.
- Add cabling for concept devices: ConceptComponent (interfaces,
  front/rear ports, power ports/outlets, console ports) and
  ConceptCable (a connection between two components, restricted to
  devices in the same concept). components.sync_components()
  populates a device's components from its device type's templates,
  including the front/rear port pass-through mapping copied from
  DeviceType.port_mappings - so a device with front/rear port
  templates (a patch panel being the common case) behaves exactly
  like a real one, without hardcoding anything role-specific.
  Placeholder devices without a device type get components added by
  hand. Full CRUD views, tables, filtersets, API endpoints and a
  "Connect" action wired in from the device detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:18:50 +02:00
co-authored by Claude Sonnet 5
parent e4817ea83b
commit 63538c2efe
24 changed files with 1293 additions and 33 deletions
+15 -3
View File
@@ -38,10 +38,15 @@ def foreground_color(bg_color):
class ElevationUnit:
"""One labelled rack unit row."""
def __init__(self, number, y):
def __init__(self, number, y, occupied=False):
self.number = number
self.y = y
self.height = UNIT_HEIGHT
# Whether *anything* renders over this unit on this face - including a
# reduced-opacity "opposite" block for a full-depth device. A unit only counts
# as free (and thus click-to-add) when nothing at all sits on it, which folds
# back to plain occupied/free for full-width-only setups, same as core NetBox.
self.occupied = occupied
class ElevationBlock:
@@ -110,11 +115,14 @@ class Elevation:
self.legend_width = LEGEND_WIDTH
self.rack_width = RACK_WIDTH
self.rack_x = RACK_X
self.rack_right = RACK_X + RACK_WIDTH
self.width = RACK_X + RACK_WIDTH + MARGIN
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
self._build_units()
# Blocks first: unit occupancy (used to decide which units are click-to-add)
# depends on where they land.
self._build_blocks(devices if devices is not None else concept.devices.all())
self._build_units()
@property
def face_label(self):
@@ -134,7 +142,11 @@ class Elevation:
def _build_units(self):
for number in self.concept.units:
top = number if self.concept.desc_units else number + 1
self.units.append(ElevationUnit(number, self._y_for(Decimal(top))))
occupied = any(
block.device.unit_span and block.device.unit_span[0] <= number < block.device.unit_span[1]
for block in self.blocks
)
self.units.append(ElevationUnit(number, self._y_for(Decimal(top)), occupied))
def _build_blocks(self, devices):
for device in devices: