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:
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import DeviceFaceChoices, RackAirflowChoices, RackFormFactorChoices, RackWidthChoices
|
||||
from dcim.choices import CableTypeChoices, DeviceFaceChoices, LinkStatusChoices, RackAirflowChoices, \
|
||||
RackFormFactorChoices, RackWidthChoices
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Rack, RackRole, RackType, Site
|
||||
from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -12,10 +13,15 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
|
||||
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
|
||||
from .models import ConceptDevice, RackConcept
|
||||
from .choices import ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices, \
|
||||
WidthFractionChoices
|
||||
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
||||
|
||||
__all__ = (
|
||||
'ConceptCableForm',
|
||||
'ConceptCableFilterForm',
|
||||
'ConceptComponentForm',
|
||||
'ConceptComponentFilterForm',
|
||||
'ConceptDeployForm',
|
||||
'ConceptDeviceBulkEditForm',
|
||||
'ConceptDeviceFilterForm',
|
||||
@@ -631,3 +637,125 @@ class ConceptDeployForm(forms.Form):
|
||||
})
|
||||
cleaned['rack_names'] = names
|
||||
return cleaned
|
||||
|
||||
|
||||
#
|
||||
# Cabling
|
||||
#
|
||||
|
||||
class ConceptComponentForm(NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=ConceptDevice.objects.all(),
|
||||
label=_('Concept device')
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('device', 'component_type', 'name', 'label', 'description', 'tags', name=_('Component')),
|
||||
FieldSet('port_type', 'positions', 'color', name=_('Attributes')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConceptComponent
|
||||
fields = (
|
||||
'device', 'component_type', 'name', 'label', 'port_type', 'positions', 'color', 'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
class ConceptComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
model = ConceptComponent
|
||||
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ConceptDevice.objects.all(),
|
||||
required=False,
|
||||
label=_('Concept device')
|
||||
)
|
||||
concept_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RackConcept.objects.all(),
|
||||
required=False,
|
||||
label=_('Rack concept')
|
||||
)
|
||||
component_type = forms.MultipleChoiceField(choices=ConceptComponentTypeChoices, required=False)
|
||||
connected = forms.NullBooleanField(required=False, widget=forms.Select(choices=(
|
||||
('', '---------'), ('true', _('Connected')), ('false', _('Not connected')),
|
||||
)))
|
||||
tag = TagFilterField(model)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('device_id', 'concept_id', 'component_type', 'connected', name=_('Attributes')),
|
||||
)
|
||||
|
||||
|
||||
class ConceptCableForm(NetBoxModelForm):
|
||||
"""
|
||||
Mirrors the shape of NetBox's own cable-connect form: pick a device, then a
|
||||
component on it, for each end.
|
||||
"""
|
||||
concept = DynamicModelChoiceField(
|
||||
queryset=RackConcept.objects.all(),
|
||||
label=_('Rack concept'),
|
||||
help_text=_('Both ends must belong to devices in this concept')
|
||||
)
|
||||
termination_a_device = DynamicModelChoiceField(
|
||||
queryset=ConceptDevice.objects.all(),
|
||||
label=_('Device A'),
|
||||
query_params={'concept_id': '$concept'}
|
||||
)
|
||||
termination_a = DynamicModelChoiceField(
|
||||
queryset=ConceptComponent.objects.all(),
|
||||
label=_('Component A'),
|
||||
query_params={'device_id': '$termination_a_device', 'connected': 'false'}
|
||||
)
|
||||
termination_b_device = DynamicModelChoiceField(
|
||||
queryset=ConceptDevice.objects.all(),
|
||||
label=_('Device B'),
|
||||
query_params={'concept_id': '$concept'}
|
||||
)
|
||||
termination_b = DynamicModelChoiceField(
|
||||
queryset=ConceptComponent.objects.all(),
|
||||
label=_('Component B'),
|
||||
query_params={'device_id': '$termination_b_device', 'connected': 'false'}
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('concept', name=_('Scope')),
|
||||
FieldSet('termination_a_device', 'termination_a', name=_('Side A')),
|
||||
FieldSet('termination_b_device', 'termination_b', name=_('Side B')),
|
||||
FieldSet('status', 'type', 'color', 'label', 'description', 'tags', name=_('Cable')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConceptCable
|
||||
fields = (
|
||||
'termination_a', 'termination_b', 'status', 'type', 'color', 'label', 'description', 'comments', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# concept/termination_*_device are UI-only helpers for the cascading selects
|
||||
# above, not model fields - when editing an existing cable, seed them from the
|
||||
# components already chosen so the dropdowns show the right device on load.
|
||||
instance = self.instance
|
||||
if instance and instance.pk:
|
||||
self.fields['concept'].initial = instance.termination_a.device.concept_id
|
||||
self.fields['termination_a_device'].initial = instance.termination_a.device_id
|
||||
self.fields['termination_b_device'].initial = instance.termination_b.device_id
|
||||
|
||||
|
||||
class ConceptCableFilterForm(NetBoxModelFilterSetForm):
|
||||
model = ConceptCable
|
||||
|
||||
concept_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RackConcept.objects.all(),
|
||||
required=False,
|
||||
label=_('Rack concept')
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=LinkStatusChoices, required=False)
|
||||
type = forms.MultipleChoiceField(choices=CableTypeChoices, required=False)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('concept_id', 'status', 'type', name=_('Attributes')),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user