diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md
index c9b331570..e670b69ec 100644
--- a/docs/models/wireless/wirelesslink.md
+++ b/docs/models/wireless/wirelesslink.md
@@ -40,3 +40,7 @@ The security cipher used to apply wireless authentication. Options include:
### Pre-Shared Key
The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types.
+
+### Distance
+
+The numeric distance of the link, including a unit designation (e.g. 100 meters or 25 feet).
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index d5cc0e856..c1ed1049f 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -656,11 +656,6 @@ class CableForm(TenancyForm, NetBoxModelForm):
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'description', 'comments', 'tags',
]
- error_messages = {
- 'length': {
- 'max_value': _('Maximum length is 32767 (any unit)')
- }
- }
class PowerPanelForm(NetBoxModelForm):
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index 9ce5d967b..959414d75 100644
--- a/netbox/dcim/svg/cables.py
+++ b/netbox/dcim/svg/cables.py
@@ -393,6 +393,8 @@ class CableTraceSVG:
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
if cable.ssid:
description.append(f"{cable.ssid}")
+ if cable.distance and cable.distance_unit:
+ description.append(f"{cable.distance} {cable.get_distance_unit_display()}")
near = [term for term in near_terminations if term.object == cable.interface_a]
far = [term for term in far_terminations if term.object == cable.interface_b]
if not (near and far):
diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py
index 0fb2f7d61..296bfd46c 100644
--- a/netbox/dcim/tables/cables.py
+++ b/netbox/dcim/tables/cables.py
@@ -109,7 +109,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
status = columns.ChoiceFieldColumn()
length = columns.TemplateColumn(
template_code=CABLE_LENGTH,
- order_by=('_abs_length', 'length_unit')
+ order_by=('_abs_length')
)
color = columns.ColorColumn()
comments = columns.MarkdownColumn()
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html
index 3237c9ab8..b31c3132c 100644
--- a/netbox/templates/wireless/wirelesslink.html
+++ b/netbox/templates/wireless/wirelesslink.html
@@ -34,6 +34,16 @@
{% trans "Description" %} |
{{ object.description|placeholder }} |
+
+ {% trans "Distance" %} |
+
+ {% if object.distance is not None %}
+ {{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
{% include 'inc/panels/tags.html' %}
diff --git a/netbox/wireless/api/serializers_/wirelesslinks.py b/netbox/wireless/api/serializers_/wirelesslinks.py
index 3a7f88856..7c8138f9b 100644
--- a/netbox/wireless/api/serializers_/wirelesslinks.py
+++ b/netbox/wireless/api/serializers_/wirelesslinks.py
@@ -21,11 +21,13 @@ class WirelessLinkSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
+ distance_unit = ChoiceField(choices=WirelessLinkDistanceUnitChoices, allow_blank=True, required=False, allow_null=True)
class Meta:
model = WirelessLink
fields = [
'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type',
- 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'auth_cipher', 'auth_psk', 'distance', 'distance_unit', 'description', 'comments', 'tags', 'custom_fields',
+ 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'ssid', 'description')
diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py
index 710cd3a8d..f17ea584d 100644
--- a/netbox/wireless/choices.py
+++ b/netbox/wireless/choices.py
@@ -481,3 +481,21 @@ class WirelessAuthCipherChoices(ChoiceSet):
(CIPHER_TKIP, 'TKIP'),
(CIPHER_AES, 'AES'),
)
+
+
+class WirelessLinkDistanceUnitChoices(ChoiceSet):
+
+ # Metric
+ UNIT_KILOMETER = 'km'
+ UNIT_METER = 'm'
+
+ # Imperial
+ UNIT_MILE = 'mi'
+ UNIT_FOOT = 'ft'
+
+ CHOICES = (
+ (UNIT_KILOMETER, _('Kilometers')),
+ (UNIT_METER, _('Meters')),
+ (UNIT_MILE, _('Miles')),
+ (UNIT_FOOT, _('Feet')),
+ )
diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py
index da66df144..9f60388ce 100644
--- a/netbox/wireless/filtersets.py
+++ b/netbox/wireless/filtersets.py
@@ -105,7 +105,7 @@ class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = WirelessLink
- fields = ('id', 'ssid', 'auth_psk', 'description')
+ fields = ('id', 'ssid', 'auth_psk', 'distance', 'distance_unit', 'description')
def search(self, queryset, name, value):
if not value.strip():
diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py
index 84916e8d9..64a9bfa98 100644
--- a/netbox/wireless/forms/bulk_edit.py
+++ b/netbox/wireless/forms/bulk_edit.py
@@ -125,6 +125,17 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
required=False,
label=_('Pre-shared key')
)
+ distance = forms.DecimalField(
+ label=_('Distance'),
+ min_value=0,
+ required=False
+ )
+ distance_unit = forms.ChoiceField(
+ label=_('Distance unit'),
+ choices=add_blank_choice(WirelessLinkDistanceUnitChoices),
+ required=False,
+ initial=''
+ )
description = forms.CharField(
label=_('Description'),
max_length=200,
@@ -135,8 +146,9 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
model = WirelessLink
fieldsets = (
FieldSet('ssid', 'status', 'tenant', 'description'),
- FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication'))
+ FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
+ FieldSet('distance', 'distance_unit', name=_('Attributes')),
)
nullable_fields = (
- 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'comments',
+ 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'distance', 'comments',
)
diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py
index 38bc37360..878afd5c8 100644
--- a/netbox/wireless/forms/bulk_import.py
+++ b/netbox/wireless/forms/bulk_import.py
@@ -112,10 +112,16 @@ class WirelessLinkImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Authentication cipher')
)
+ distance_unit = CSVChoiceField(
+ label=_('Distance unit'),
+ choices=WirelessLinkDistanceUnitChoices,
+ required=False,
+ help_text=_('Distance unit')
+ )
class Meta:
model = WirelessLink
fields = (
- 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description',
- 'comments', 'tags',
+ 'interface_a', 'interface_b', 'ssid', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
+ 'distance', 'distance_unit', 'description', 'comments', 'tags',
)
diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py
index 2458d7b48..f87cadfb9 100644
--- a/netbox/wireless/forms/filtersets.py
+++ b/netbox/wireless/forms/filtersets.py
@@ -71,7 +71,7 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = WirelessLink
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
- FieldSet('ssid', 'status', name=_('Attributes')),
+ FieldSet('ssid', 'status', 'distance', 'distance_unit', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
@@ -98,4 +98,13 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Pre-shared key'),
required=False
)
+ distance = forms.DecimalField(
+ label=_('Distance'),
+ required=False,
+ )
+ distance_unit = forms.ChoiceField(
+ label=_('Distance unit'),
+ choices=add_blank_choice(WirelessLinkDistanceUnitChoices),
+ required=False
+ )
tag = TagFilterField(model)
diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py
index 05debf8bf..6d46373ac 100644
--- a/netbox/wireless/forms/model_forms.py
+++ b/netbox/wireless/forms/model_forms.py
@@ -159,7 +159,7 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm):
fieldsets = (
FieldSet('site_a', 'location_a', 'device_a', 'interface_a', name=_('Side A')),
FieldSet('site_b', 'location_b', 'device_b', 'interface_b', name=_('Side B')),
- FieldSet('status', 'ssid', 'description', 'tags', name=_('Link')),
+ FieldSet('status', 'ssid', 'distance', 'distance_unit', 'description', 'tags', name=_('Link')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
@@ -168,8 +168,8 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm):
model = WirelessLink
fields = [
'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b',
- 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description',
- 'comments', 'tags',
+ 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
+ 'distance', 'distance_unit', 'description', 'comments', 'tags',
]
widgets = {
'auth_psk': PasswordInput(
diff --git a/netbox/wireless/migrations/0009_wirelesslink_distance.py b/netbox/wireless/migrations/0009_wirelesslink_distance.py
new file mode 100644
index 000000000..6a778ef00
--- /dev/null
+++ b/netbox/wireless/migrations/0009_wirelesslink_distance.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.0.6 on 2024-06-12 18:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('wireless', '0001_squashed_0008'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wirelesslink',
+ name='_abs_distance',
+ field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True),
+ ),
+ migrations.AddField(
+ model_name='wirelesslink',
+ name='distance',
+ field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
+ ),
+ migrations.AddField(
+ model_name='wirelesslink',
+ name='distance_unit',
+ field=models.CharField(blank=True, max_length=50),
+ ),
+ ]
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
index 0b114f85f..4214ac29d 100644
--- a/netbox/wireless/models.py
+++ b/netbox/wireless/models.py
@@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
from netbox.models import NestedGroupModel, PrimaryModel
+from utilities.conversion import to_meters
from .choices import *
from .constants import *
@@ -160,6 +161,26 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED
)
+ distance = models.DecimalField(
+ verbose_name=_('distance'),
+ max_digits=8,
+ decimal_places=2,
+ blank=True,
+ null=True
+ )
+ distance_unit = models.CharField(
+ verbose_name=_('distance unit'),
+ max_length=50,
+ choices=WirelessLinkDistanceUnitChoices,
+ blank=True,
+ )
+ # Stores the normalized distance (in meters) for database ordering
+ _abs_distance = models.DecimalField(
+ max_digits=10,
+ decimal_places=4,
+ blank=True,
+ null=True
+ )
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -208,6 +229,11 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
return LinkStatusChoices.colors.get(self.status)
def clean(self):
+ super().clean()
+
+ # Validate distance and distance_unit
+ if self.distance is not None and not self.distance_unit:
+ raise ValidationError(_("Must specify a unit when setting a wireless distance"))
# Validate interface types
if self.interface_a.type not in WIRELESS_IFACE_TYPES:
@@ -224,6 +250,15 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
})
def save(self, *args, **kwargs):
+ # Store the given distance (if any) in meters for use in database ordering
+ if self.distance is not None and self.distance_unit:
+ self._abs_distance = to_meters(self.distance, self.distance_unit)
+ else:
+ self._abs_distance = None
+
+ # Clear distance_unit if no distance is defined
+ if self.distance is None:
+ self.distance_unit = ''
# Store the parent Device for the A and B interfaces
self._interface_a_device = self.interface_a.device
diff --git a/netbox/wireless/tables/template_code.py b/netbox/wireless/tables/template_code.py
new file mode 100644
index 000000000..03c893c1f
--- /dev/null
+++ b/netbox/wireless/tables/template_code.py
@@ -0,0 +1,4 @@
+WIRELESS_LINK_DISTANCE = """
+{% load helpers %}
+{% if record.distance %}{{ record.distance|floatformat:"-2" }} {{ record.distance_unit }}{% endif %}
+"""
diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py
index 7c3b3306b..9d3a50848 100644
--- a/netbox/wireless/tables/wirelesslink.py
+++ b/netbox/wireless/tables/wirelesslink.py
@@ -4,6 +4,7 @@ import django_tables2 as tables
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from wireless.models import *
+from .template_code import WIRELESS_LINK_DISTANCE
__all__ = (
'WirelessLinkTable',
@@ -36,6 +37,10 @@ class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Interface B'),
linkify=True
)
+ distance = columns.TemplateColumn(
+ template_code=WIRELESS_LINK_DISTANCE,
+ order_by=('_abs_distance')
+ )
tags = columns.TagColumn(
url_name='wireless:wirelesslink_list'
)
@@ -44,7 +49,8 @@ class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable):
model = WirelessLink
fields = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'tenant',
- 'tenant_group', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated',
+ 'tenant_group', 'distance', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
+ 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type',
diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py
index f67d42439..cd26debf7 100644
--- a/netbox/wireless/tests/test_api.py
+++ b/netbox/wireless/tests/test_api.py
@@ -113,6 +113,8 @@ class WirelessLinkTest(APIViewTestCases.APIViewTestCase):
brief_fields = ['description', 'display', 'id', 'ssid', 'url']
bulk_update_data = {
'status': 'planned',
+ 'distance': 100,
+ 'distance_unit': 'm',
}
@classmethod
diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py
index 72264a158..46eec4d7b 100644
--- a/netbox/wireless/tests/test_filtersets.py
+++ b/netbox/wireless/tests/test_filtersets.py
@@ -260,6 +260,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
auth_psk='PSK1',
tenant=tenants[0],
+ distance=10,
+ distance_unit=WirelessLinkDistanceUnitChoices.UNIT_FOOT,
description='foobar1'
).save()
WirelessLink(
@@ -271,6 +273,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
auth_psk='PSK2',
tenant=tenants[1],
+ distance=20,
+ distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER,
description='foobar2'
).save()
WirelessLink(
@@ -281,6 +285,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
auth_psk='PSK3',
+ distance=30,
+ distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER,
tenant=tenants[2],
).save()
WirelessLink(
@@ -313,6 +319,14 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'auth_psk': ['PSK1', 'PSK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_distance(self):
+ params = {'distance': [10, 20]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_distance_unit(self):
+ params = {'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py
index 62c3b451f..055edf73c 100644
--- a/netbox/wireless/tests/test_views.py
+++ b/netbox/wireless/tests/test_views.py
@@ -160,6 +160,8 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'interface_a': interfaces[6].pk,
'interface_b': interfaces[7].pk,
'status': LinkStatusChoices.STATUS_PLANNED,
+ 'distance': 100,
+ 'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT,
'tenant': tenants[1].pk,
'tags': [t.pk for t in tags],
}
@@ -180,4 +182,6 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'status': LinkStatusChoices.STATUS_PLANNED,
+ 'distance': 50,
+ 'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_METER,
}