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
+79
View File
@@ -0,0 +1,79 @@
"""
Populate a planned device's components from its device type's templates - interfaces,
ports, and the front/rear pass-through mapping - so a concept device with a device type
can be cabled exactly like a real one. A device type's own component templates are the
single source of truth here; nothing about this is specific to any particular role, so
a "Patchpanel"-role device gets correct front/rear pass-through behaviour simply because
that's what its device type's templates already describe - the same as a real device.
Placeholder devices without a device type have no templates to sync from; their
components, if any, have to be added by hand.
"""
from django.db import transaction
from .choices import ConceptComponentTypeChoices
from .models import ConceptComponent, ConceptPortMapping
__all__ = ('sync_components',)
# (our component type, DeviceType's reverse accessor for that template queryset)
TEMPLATE_SOURCES = (
(ConceptComponentTypeChoices.TYPE_INTERFACE, 'interfacetemplates'),
(ConceptComponentTypeChoices.TYPE_CONSOLE_PORT, 'consoleporttemplates'),
(ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT, 'consoleserverporttemplates'),
(ConceptComponentTypeChoices.TYPE_POWER_PORT, 'powerporttemplates'),
(ConceptComponentTypeChoices.TYPE_POWER_OUTLET, 'poweroutlettemplates'),
(ConceptComponentTypeChoices.TYPE_FRONT_PORT, 'frontporttemplates'),
(ConceptComponentTypeChoices.TYPE_REAR_PORT, 'rearporttemplates'),
)
@transaction.atomic
def sync_components(concept_device):
"""
Create whichever of the device type's components don't already exist on this
device (matched by name), and mirror its front/rear port mapping. Existing
components - and any cables already attached to them - are left untouched, so this
is safe to re-run after adding extra components by hand or after the device type
changes. Returns (components_created, mappings_created).
"""
device_type = concept_device.device_type
if device_type is None:
return 0, 0
existing = {component.name: component for component in concept_device.components.all()}
created = 0
for component_type, accessor in TEMPLATE_SOURCES:
for template in getattr(device_type, accessor).all():
if template.name in existing:
continue
component = ConceptComponent.objects.create(
device=concept_device,
component_type=component_type,
name=template.name,
label=template.label,
port_type=getattr(template, 'type', '') or '',
positions=getattr(template, 'positions', 1) or 1,
color=getattr(template, 'color', '') or '',
)
existing[component.name] = component
created += 1
mappings_created = 0
for mapping in device_type.port_mappings.select_related('front_port', 'rear_port').all():
front = existing.get(mapping.front_port.name)
rear = existing.get(mapping.rear_port.name)
if not (front and rear):
# Shouldn't happen - the templates were just synced above - but a
# partially-synced device from before a device type change could hit this.
continue
_, was_created = ConceptPortMapping.objects.get_or_create(
front_component=front,
front_position=mapping.front_port_position,
defaults={'rear_component': rear, 'rear_position': mapping.rear_port_position}
)
if was_created:
mappings_created += 1
return created, mappings_created