Skip to content

Commit 17d8f78

Browse files
Closes #20564: Many-to-many pass-through port mappings (#20851)
1 parent 97d0a16 commit 17d8f78

35 files changed

+2501
-930
lines changed

netbox/dcim/api/serializers_/base.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from drf_spectacular.utils import extend_schema_field
33
from rest_framework import serializers
44

5+
from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
56
from utilities.api import get_serializer_for_model
67

78
__all__ = (
89
'ConnectedEndpointsSerializer',
10+
'PortSerializer',
911
)
1012

1113

@@ -35,3 +37,53 @@ def get_connected_endpoints(self, obj):
3537
@extend_schema_field(serializers.BooleanField)
3638
def get_connected_endpoints_reachable(self, obj):
3739
return obj._path and obj._path.is_complete and obj._path.is_active
40+
41+
42+
class PortSerializer(serializers.ModelSerializer):
43+
"""
44+
Base serializer for front & rear port and port templates.
45+
"""
46+
@property
47+
def _mapper(self):
48+
"""
49+
Return the model and ForeignKey field name used to track port mappings for this model.
50+
"""
51+
if self.Meta.model is FrontPort:
52+
return PortMapping, 'front_port'
53+
if self.Meta.model is RearPort:
54+
return PortMapping, 'rear_port'
55+
if self.Meta.model is FrontPortTemplate:
56+
return PortTemplateMapping, 'front_port'
57+
if self.Meta.model is RearPortTemplate:
58+
return PortTemplateMapping, 'rear_port'
59+
raise ValueError(f"Could not determine mapping details for {self.__class__}")
60+
61+
def create(self, validated_data):
62+
mappings = validated_data.pop('mappings', [])
63+
instance = super().create(validated_data)
64+
65+
# Create port mappings
66+
mapping_model, fk_name = self._mapper
67+
for attrs in mappings:
68+
mapping_model.objects.create(**{
69+
fk_name: instance,
70+
**attrs,
71+
})
72+
73+
return instance
74+
75+
def update(self, instance, validated_data):
76+
mappings = validated_data.pop('mappings', None)
77+
instance = super().update(instance, validated_data)
78+
79+
if mappings is not None:
80+
# Update port mappings
81+
mapping_model, fk_name = self._mapper
82+
mapping_model.objects.filter(**{fk_name: instance}).delete()
83+
for attrs in mappings:
84+
mapping_model.objects.create(**{
85+
fk_name: instance,
86+
**attrs,
87+
})
88+
89+
return instance

netbox/dcim/api/serializers_/device_components.py

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@
55
from dcim.choices import *
66
from dcim.constants import *
77
from dcim.models import (
8-
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
9-
RearPort, VirtualDeviceContext,
8+
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping,
9+
PowerOutlet, PowerPort, RearPort, VirtualDeviceContext,
1010
)
1111
from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
1212
from ipam.api.serializers_.vrfs import VRFSerializer
1313
from ipam.models import VLAN
1414
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
1515
from netbox.api.gfk_fields import GFKSerializerField
16-
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
16+
from netbox.api.serializers import NetBoxModelSerializer
1717
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
1818
from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
1919
from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
2020
from wireless.choices import *
2121
from wireless.models import WirelessLAN
22-
from .base import ConnectedEndpointsSerializer
22+
from .base import ConnectedEndpointsSerializer, PortSerializer
2323
from .cables import CabledObjectSerializer
2424
from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
2525
from .manufacturers import ManufacturerSerializer
@@ -294,7 +294,20 @@ def validate(self, data):
294294
return super().validate(data)
295295

296296

297-
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
297+
class RearPortMappingSerializer(serializers.ModelSerializer):
298+
position = serializers.IntegerField(
299+
source='rear_port_position'
300+
)
301+
front_port = serializers.PrimaryKeyRelatedField(
302+
queryset=FrontPort.objects.all(),
303+
)
304+
305+
class Meta:
306+
model = PortMapping
307+
fields = ('position', 'front_port', 'front_port_position')
308+
309+
310+
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
298311
device = DeviceSerializer(nested=True)
299312
module = ModuleSerializer(
300313
nested=True,
@@ -303,28 +316,36 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
303316
allow_null=True
304317
)
305318
type = ChoiceField(choices=PortTypeChoices)
319+
front_ports = RearPortMappingSerializer(
320+
source='mappings',
321+
many=True,
322+
required=False,
323+
)
306324

307325
class Meta:
308326
model = RearPort
309327
fields = [
310328
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
311-
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
312-
'custom_fields', 'created', 'last_updated', '_occupied',
329+
'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
330+
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
313331
]
314332
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
315333

316334

317-
class FrontPortRearPortSerializer(WritableNestedSerializer):
318-
"""
319-
NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
320-
"""
335+
class FrontPortMappingSerializer(serializers.ModelSerializer):
336+
position = serializers.IntegerField(
337+
source='front_port_position'
338+
)
339+
rear_port = serializers.PrimaryKeyRelatedField(
340+
queryset=RearPort.objects.all(),
341+
)
321342

322343
class Meta:
323-
model = RearPort
324-
fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
344+
model = PortMapping
345+
fields = ('position', 'rear_port', 'rear_port_position')
325346

326347

327-
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
348+
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
328349
device = DeviceSerializer(nested=True)
329350
module = ModuleSerializer(
330351
nested=True,
@@ -333,14 +354,18 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
333354
allow_null=True
334355
)
335356
type = ChoiceField(choices=PortTypeChoices)
336-
rear_port = FrontPortRearPortSerializer()
357+
rear_ports = FrontPortMappingSerializer(
358+
source='mappings',
359+
many=True,
360+
required=False,
361+
)
337362

338363
class Meta:
339364
model = FrontPort
340365
fields = [
341-
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
342-
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
343-
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
366+
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
367+
'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
368+
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
344369
]
345370
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
346371

netbox/dcim/api/serializers_/devicetype_components.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
from dcim.constants import *
66
from dcim.models import (
77
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
8-
InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
8+
InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate,
9+
RearPortTemplate,
910
)
1011
from netbox.api.fields import ChoiceField, ContentTypeField
1112
from netbox.api.gfk_fields import GFKSerializerField
1213
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
1314
from wireless.choices import *
15+
from .base import PortSerializer
1416
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
1517
from .manufacturers import ManufacturerSerializer
1618
from .nested import NestedInterfaceTemplateSerializer
@@ -205,7 +207,20 @@ class Meta:
205207
brief_fields = ('id', 'url', 'display', 'name', 'description')
206208

207209

208-
class RearPortTemplateSerializer(ComponentTemplateSerializer):
210+
class RearPortTemplateMappingSerializer(serializers.ModelSerializer):
211+
position = serializers.IntegerField(
212+
source='rear_port_position'
213+
)
214+
front_port = serializers.PrimaryKeyRelatedField(
215+
queryset=FrontPortTemplate.objects.all(),
216+
)
217+
218+
class Meta:
219+
model = PortTemplateMapping
220+
fields = ('position', 'front_port', 'front_port_position')
221+
222+
223+
class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
209224
device_type = DeviceTypeSerializer(
210225
required=False,
211226
nested=True,
@@ -219,17 +234,35 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer):
219234
default=None
220235
)
221236
type = ChoiceField(choices=PortTypeChoices)
237+
front_ports = RearPortTemplateMappingSerializer(
238+
source='mappings',
239+
many=True,
240+
required=False,
241+
)
222242

223243
class Meta:
224244
model = RearPortTemplate
225245
fields = [
226-
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
227-
'positions', 'description', 'created', 'last_updated',
246+
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
247+
'front_ports', 'description', 'created', 'last_updated',
228248
]
229249
brief_fields = ('id', 'url', 'display', 'name', 'description')
230250

231251

232-
class FrontPortTemplateSerializer(ComponentTemplateSerializer):
252+
class FrontPortTemplateMappingSerializer(serializers.ModelSerializer):
253+
position = serializers.IntegerField(
254+
source='front_port_position'
255+
)
256+
rear_port = serializers.PrimaryKeyRelatedField(
257+
queryset=RearPortTemplate.objects.all(),
258+
)
259+
260+
class Meta:
261+
model = PortTemplateMapping
262+
fields = ('position', 'rear_port', 'rear_port_position')
263+
264+
265+
class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
233266
device_type = DeviceTypeSerializer(
234267
nested=True,
235268
required=False,
@@ -243,13 +276,17 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer):
243276
default=None
244277
)
245278
type = ChoiceField(choices=PortTypeChoices)
246-
rear_port = RearPortTemplateSerializer(nested=True)
279+
rear_ports = FrontPortTemplateMappingSerializer(
280+
source='mappings',
281+
many=True,
282+
required=False,
283+
)
247284

248285
class Meta:
249286
model = FrontPortTemplate
250287
fields = [
251-
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
252-
'rear_port', 'rear_port_position', 'description', 'created', 'last_updated',
288+
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
289+
'rear_ports', 'description', 'created', 'last_updated',
253290
]
254291
brief_fields = ('id', 'url', 'display', 'name', 'description')
255292

netbox/dcim/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
# RearPorts
3333
#
3434

35-
REARPORT_POSITIONS_MIN = 1
36-
REARPORT_POSITIONS_MAX = 1024
35+
PORT_POSITION_MIN = 1
36+
PORT_POSITION_MAX = 1024
3737

3838

3939
#

netbox/dcim/filtersets.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -904,12 +904,15 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
904904
null_value=None
905905
)
906906
rear_port_id = django_filters.ModelMultipleChoiceFilter(
907-
queryset=RearPort.objects.all()
907+
field_name='mappings__rear_port',
908+
queryset=RearPort.objects.all(),
909+
to_field_name='rear_port',
910+
label=_('Rear port (ID)'),
908911
)
909912

910913
class Meta:
911914
model = FrontPortTemplate
912-
fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
915+
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
913916

914917

915918
@register_filterset
@@ -918,6 +921,12 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
918921
choices=PortTypeChoices,
919922
null_value=None
920923
)
924+
front_port_id = django_filters.ModelMultipleChoiceFilter(
925+
field_name='mappings__front_port',
926+
queryset=FrontPort.objects.all(),
927+
to_field_name='front_port',
928+
label=_('Front port (ID)'),
929+
)
921930

922931
class Meta:
923932
model = RearPortTemplate
@@ -2148,13 +2157,16 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
21482157
null_value=None
21492158
)
21502159
rear_port_id = django_filters.ModelMultipleChoiceFilter(
2151-
queryset=RearPort.objects.all()
2160+
field_name='mappings__rear_port',
2161+
queryset=RearPort.objects.all(),
2162+
to_field_name='rear_port',
2163+
label=_('Rear port (ID)'),
21522164
)
21532165

21542166
class Meta:
21552167
model = FrontPort
21562168
fields = (
2157-
'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
2169+
'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
21582170
'cable_position',
21592171
)
21602172

@@ -2165,6 +2177,12 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
21652177
choices=PortTypeChoices,
21662178
null_value=None
21672179
)
2180+
front_port_id = django_filters.ModelMultipleChoiceFilter(
2181+
field_name='mappings__front_port',
2182+
queryset=FrontPort.objects.all(),
2183+
to_field_name='front_port',
2184+
label=_('Front port (ID)'),
2185+
)
21682186

21692187
class Meta:
21702188
model = RearPort

0 commit comments

Comments
 (0)