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, }