Răsfoiți Sursa

16547 Add distance to Circuit (#17629)

* 16547 Add distance to Circuit

* 16547 fix test cases

* 16547 fix test cases

* 16547 add distance to API, forms, tables

* 16547 fixes

* 16547 fixes

* 16547 review changes

* 16547 review changes

* Clean up DistanceColumn

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson 1 an în urmă
părinte
comite
65687851fe
38 a modificat fișierele cu 290 adăugiri și 150 ștergeri
  1. 6 0
      docs/models/circuits/circuit.md
  2. 3 1
      netbox/circuits/api/serializers_/circuits.py
  3. 1 1
      netbox/circuits/filtersets.py
  4. 13 0
      netbox/circuits/forms/bulk_edit.py
  5. 8 1
      netbox/circuits/forms/bulk_import.py
  6. 12 1
      netbox/circuits/forms/filtersets.py
  7. 13 3
      netbox/circuits/forms/model_forms.py
  8. 28 0
      netbox/circuits/migrations/0045_circuit_distance.py
  9. 2 1
      netbox/circuits/models/circuits.py
  10. 1 0
      netbox/circuits/tables/circuits.py
  11. 12 3
      netbox/circuits/tests/test_filtersets.py
  12. 1 0
      netbox/dcim/api/serializers_/devicetypes.py
  13. 1 0
      netbox/dcim/api/serializers_/racks.py
  14. 0 18
      netbox/dcim/choices.py
  15. 1 0
      netbox/dcim/forms/bulk_edit.py
  16. 1 0
      netbox/dcim/forms/bulk_import.py
  17. 1 0
      netbox/dcim/forms/filtersets.py
  18. 2 1
      netbox/dcim/models/devices.py
  19. 0 44
      netbox/dcim/models/mixins.py
  20. 1 1
      netbox/dcim/models/racks.py
  21. 1 1
      netbox/dcim/tests/test_filtersets.py
  22. 1 0
      netbox/dcim/tests/test_models.py
  23. 1 1
      netbox/dcim/tests/test_views.py
  24. 38 0
      netbox/netbox/choices.py
  25. 97 0
      netbox/netbox/models/mixins.py
  26. 14 0
      netbox/netbox/tables/columns.py
  27. 10 0
      netbox/templates/circuits/circuit.html
  28. 2 1
      netbox/utilities/conversion.py
  29. 2 1
      netbox/wireless/api/serializers_/wirelesslinks.py
  30. 0 18
      netbox/wireless/choices.py
  31. 2 1
      netbox/wireless/forms/bulk_edit.py
  32. 2 1
      netbox/wireless/forms/bulk_import.py
  33. 2 1
      netbox/wireless/forms/filtersets.py
  34. 2 35
      netbox/wireless/models.py
  35. 0 4
      netbox/wireless/tables/template_code.py
  36. 1 5
      netbox/wireless/tables/wirelesslink.py
  37. 5 4
      netbox/wireless/tests/test_filtersets.py
  38. 3 2
      netbox/wireless/tests/test_views.py

+ 6 - 0
docs/models/circuits/circuit.md

@@ -36,6 +36,12 @@ The operational status of the circuit. By default, the following statuses are av
 !!! tip "Custom circuit statuses"
     Additional circuit statuses may be defined by setting `Circuit.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
+### Distance
+
+!!! info "This field was introduced in NetBox v4.2."
+
+The distance between the circuit's two endpoints, including a unit designation (e.g. 100 meters or 25 feet).
+
 ### Description
 
 A brief description of the circuit.

+ 3 - 1
netbox/circuits/api/serializers_/circuits.py

@@ -4,6 +4,7 @@ from dcim.api.serializers_.cables import CabledObjectSerializer
 from dcim.api.serializers_.sites import SiteSerializer
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
+from netbox.choices import DistanceUnitChoices
 from tenancy.api.serializers_.tenants import TenantSerializer
 
 from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
@@ -80,13 +81,14 @@ class CircuitSerializer(NetBoxModelSerializer):
     termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
     assignments = CircuitGroupAssignmentSerializer_(nested=True, many=True, required=False)
+    distance_unit = ChoiceField(choices=DistanceUnitChoices, allow_blank=True, required=False, allow_null=True)
 
     class Meta:
         model = Circuit
         fields = [
             'id', 'url', 'display_url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant',
             'install_date', 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'assignments',
+            'distance', 'distance_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'assignments',
         ]
         brief_fields = ('id', 'url', 'display', 'provider', 'cid', 'description')
 

+ 1 - 1
netbox/circuits/filtersets.py

@@ -239,7 +239,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
 
     class Meta:
         model = Circuit
-        fields = ('id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate')
+        fields = ('id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit')
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 13 - 0
netbox/circuits/forms/bulk_edit.py

@@ -5,6 +5,7 @@ from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, C
 from circuits.models import *
 from dcim.models import Site
 from ipam.models import ASN
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
@@ -160,6 +161,17 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
             options=CircuitCommitRateChoices
         )
     )
+    distance = forms.DecimalField(
+        label=_('Distance'),
+        min_value=0,
+        required=False
+    )
+    distance_unit = forms.ChoiceField(
+        label=_('Distance unit'),
+        choices=add_blank_choice(DistanceUnitChoices),
+        required=False,
+        initial=''
+    )
     description = forms.CharField(
         label=_('Description'),
         max_length=100,
@@ -171,6 +183,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
     fieldsets = (
         FieldSet('provider', 'type', 'status', 'description', name=_('Circuit')),
         FieldSet('provider_account', 'install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')),
+        FieldSet('distance', 'distance_unit', name=_('Attributes')),
         FieldSet('tenant', name=_('Tenancy')),
     )
     nullable_fields = (

+ 8 - 1
netbox/circuits/forms/bulk_import.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 from circuits.choices import *
 from circuits.models import *
 from dcim.models import Site
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@@ -95,6 +96,12 @@ class CircuitImportForm(NetBoxModelImportForm):
         choices=CircuitStatusChoices,
         help_text=_('Operational status')
     )
+    distance_unit = CSVChoiceField(
+        label=_('Distance unit'),
+        choices=DistanceUnitChoices,
+        required=False,
+        help_text=_('Distance unit')
+    )
     tenant = CSVModelChoiceField(
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
@@ -107,7 +114,7 @@ class CircuitImportForm(NetBoxModelImportForm):
         model = Circuit
         fields = [
             'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date',
-            'commit_rate', 'description', 'comments', 'tags'
+            'commit_rate', 'distance', 'distance_unit', 'description', 'comments', 'tags'
         ]
 
 

+ 12 - 1
netbox/circuits/forms/filtersets.py

@@ -5,8 +5,10 @@ from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, C
 from circuits.models import *
 from dcim.models import Region, Site, SiteGroup
 from ipam.models import ASN
+from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
+from utilities.forms import add_blank_choice
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DatePicker, NumberWithOptions
@@ -114,7 +116,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
-        FieldSet('type_id', 'status', 'install_date', 'termination_date', 'commit_rate', name=_('Attributes')),
+        FieldSet('type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -188,6 +190,15 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
             options=CircuitCommitRateChoices
         )
     )
+    distance = forms.DecimalField(
+        label=_('Distance'),
+        required=False,
+    )
+    distance_unit = forms.ChoiceField(
+        label=_('Distance unit'),
+        choices=add_blank_choice(DistanceUnitChoices),
+        required=False
+    )
     tag = TagFilterField(model)
 
 

+ 13 - 3
netbox/circuits/forms/model_forms.py

@@ -7,7 +7,7 @@ from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
-from utilities.forms.rendering import FieldSet, TabbedGroups
+from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
 from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 __all__ = (
@@ -108,7 +108,17 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
 
     fieldsets = (
-        FieldSet('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags', name=_('Circuit')),
+        FieldSet(
+            'provider',
+            'provider_account',
+            'cid',
+            'type',
+            'status',
+            InlineFields('distance', 'distance_unit', label=_('Distance')),
+            'description',
+            'tags',
+            name=_('Circuit')
+        ),
         FieldSet('install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
@@ -117,7 +127,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
         model = Circuit
         fields = [
             'cid', 'type', 'provider', 'provider_account', 'status', 'install_date', 'termination_date', 'commit_rate',
-            'description', 'tenant_group', 'tenant', 'comments', 'tags',
+            'distance', 'distance_unit', 'description', 'tenant_group', 'tenant', 'comments', 'tags',
         ]
         widgets = {
             'install_date': DatePicker(),

+ 28 - 0
netbox/circuits/migrations/0045_circuit_distance.py

@@ -0,0 +1,28 @@
+# Generated by Django 5.0.9 on 2024-09-26 22:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0044_circuit_groups'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='_abs_distance',
+            field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True),
+        ),
+        migrations.AddField(
+            model_name='circuit',
+            name='distance',
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
+        ),
+        migrations.AddField(
+            model_name='circuit',
+            name='distance_unit',
+            field=models.CharField(blank=True, max_length=50),
+        ),
+    ]

+ 2 - 1
netbox/circuits/models/circuits.py

@@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
 from circuits.choices import *
 from dcim.models import CabledObjectModel
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
+from netbox.models.mixins import DistanceMixin
 from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, ImageAttachmentsMixin, TagsMixin
 from utilities.fields import ColorField
 
@@ -34,7 +35,7 @@ class CircuitType(OrganizationalModel):
         verbose_name_plural = _('circuit types')
 
 
-class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
+class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel):
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular

+ 1 - 0
netbox/circuits/tables/circuits.py

@@ -76,6 +76,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     commit_rate = CommitRateColumn(
         verbose_name=_('Commit Rate')
     )
+    distance = columns.DistanceColumn()
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments')
     )

+ 12 - 3
netbox/circuits/tests/test_filtersets.py

@@ -5,6 +5,7 @@ from circuits.filtersets import *
 from circuits.models import *
 from dcim.models import Cable, Region, Site, SiteGroup
 from ipam.models import ASN, RIR
+from netbox.choices import DistanceUnitChoices
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests
 
@@ -222,9 +223,9 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         ProviderNetwork.objects.bulk_create(provider_networks)
 
         circuits = (
-            Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
-            Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
-            Circuit(provider=providers[0], provider_account=provider_accounts[1], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
+            Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1', distance=10, distance_unit=DistanceUnitChoices.UNIT_FOOT),
+            Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2', distance=20, distance_unit=DistanceUnitChoices.UNIT_METER),
+            Circuit(provider=providers[0], provider_account=provider_accounts[1], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED, distance=30, distance_unit=DistanceUnitChoices.UNIT_METER),
             Circuit(provider=providers[1], provider_account=provider_accounts[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
             Circuit(provider=providers[1], provider_account=provider_accounts[2], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
             Circuit(provider=providers[1], provider_account=provider_accounts[2], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
@@ -289,6 +290,14 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    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': DistanceUnitChoices.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)

+ 1 - 0
netbox/dcim/api/serializers_/devicetypes.py

@@ -7,6 +7,7 @@ from dcim.choices import *
 from dcim.models import DeviceType, ModuleType
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer
+from netbox.choices import *
 from .manufacturers import ManufacturerSerializer
 from .platforms import PlatformSerializer
 

+ 1 - 0
netbox/dcim/api/serializers_/racks.py

@@ -6,6 +6,7 @@ from dcim.constants import *
 from dcim.models import Rack, RackReservation, RackRole, RackType
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer
+from netbox.choices import *
 from netbox.config import ConfigItem
 from tenancy.api.serializers_.tenants import TenantSerializer
 from users.api.serializers_.users import UserSerializer

+ 0 - 18
netbox/dcim/choices.py

@@ -1546,24 +1546,6 @@ class CableLengthUnitChoices(ChoiceSet):
     )
 
 
-class WeightUnitChoices(ChoiceSet):
-
-    # Metric
-    UNIT_KILOGRAM = 'kg'
-    UNIT_GRAM = 'g'
-
-    # Imperial
-    UNIT_POUND = 'lb'
-    UNIT_OUNCE = 'oz'
-
-    CHOICES = (
-        (UNIT_KILOGRAM, _('Kilograms')),
-        (UNIT_GRAM, _('Grams')),
-        (UNIT_POUND, _('Pounds')),
-        (UNIT_OUNCE, _('Ounces')),
-    )
-
-
 #
 # CableTerminations
 #

+ 1 - 0
netbox/dcim/forms/bulk_edit.py

@@ -8,6 +8,7 @@ from dcim.constants import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 from ipam.models import ASN, VLAN, VLANGroup, VRF
+from netbox.choices import *
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from users.models import User

+ 1 - 0
netbox/dcim/forms/bulk_import.py

@@ -10,6 +10,7 @@ from dcim.constants import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 from ipam.models import VRF, IPAddress
+from netbox.choices import *
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from utilities.forms.fields import (

+ 1 - 0
netbox/dcim/forms/filtersets.py

@@ -7,6 +7,7 @@ from dcim.models import *
 from extras.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
 from ipam.models import ASN, VRF
+from netbox.choices import *
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from users.models import User

+ 2 - 1
netbox/dcim/models/devices.py

@@ -21,11 +21,12 @@ from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
 from netbox.config import ConfigItem
 from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
-from .mixins import RenderConfigMixin, WeightMixin
+from .mixins import RenderConfigMixin
 
 
 __all__ = (

+ 0 - 44
netbox/dcim/models/mixins.py

@@ -1,56 +1,12 @@
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.utils.translation import gettext_lazy as _
-from dcim.choices import *
-from utilities.conversion import to_grams
 
 __all__ = (
     'RenderConfigMixin',
-    'WeightMixin',
 )
 
 
-class WeightMixin(models.Model):
-    weight = models.DecimalField(
-        verbose_name=_('weight'),
-        max_digits=8,
-        decimal_places=2,
-        blank=True,
-        null=True
-    )
-    weight_unit = models.CharField(
-        verbose_name=_('weight unit'),
-        max_length=50,
-        choices=WeightUnitChoices,
-        blank=True,
-    )
-    # Stores the normalized weight (in grams) for database ordering
-    _abs_weight = models.PositiveBigIntegerField(
-        blank=True,
-        null=True
-    )
-
-    class Meta:
-        abstract = True
-
-    def save(self, *args, **kwargs):
-
-        # Store the given weight (if any) in grams for use in database ordering
-        if self.weight and self.weight_unit:
-            self._abs_weight = to_grams(self.weight, self.weight_unit)
-        else:
-            self._abs_weight = None
-
-        super().save(*args, **kwargs)
-
-    def clean(self):
-        super().clean()
-
-        # Validate weight and weight_unit
-        if self.weight and not self.weight_unit:
-            raise ValidationError(_("Must specify a unit when setting a weight"))
-
-
 class RenderConfigMixin(models.Model):
     config_template = models.ForeignKey(
         to='extras.ConfigTemplate',

+ 1 - 1
netbox/dcim/models/racks.py

@@ -16,13 +16,13 @@ from dcim.constants import *
 from dcim.svg import RackElevationSVG
 from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.conversion import to_grams
 from utilities.data import array_to_string, drange
 from utilities.fields import ColorField, NaturalOrderingField
 from .device_components import PowerPort
 from .devices import Device, Module
-from .mixins import WeightMixin
 from .power import PowerFeed
 
 __all__ = (

+ 1 - 1
netbox/dcim/tests/test_filtersets.py

@@ -5,7 +5,7 @@ from dcim.choices import *
 from dcim.filtersets import *
 from dcim.models import *
 from ipam.models import ASN, IPAddress, RIR, VRF
-from netbox.choices import ColorChoices
+from netbox.choices import ColorChoices, WeightUnitChoices
 from tenancy.models import Tenant, TenantGroup
 from users.models import User
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device

+ 1 - 0
netbox/dcim/tests/test_models.py

@@ -6,6 +6,7 @@ from core.models import ObjectType
 from dcim.choices import *
 from dcim.models import *
 from extras.models import CustomField
+from netbox.choices import WeightUnitChoices
 from tenancy.models import Tenant
 from utilities.data import drange
 from virtualization.models import Cluster, ClusterType

+ 1 - 1
netbox/dcim/tests/test_views.py

@@ -10,7 +10,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
 from ipam.models import ASN, RIR, VLAN, VRF
-from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
+from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
 from tenancy.models import Tenant
 from users.models import User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data

+ 38 - 0
netbox/netbox/choices.py

@@ -7,8 +7,10 @@ __all__ = (
     'ButtonColorChoices',
     'ColorChoices',
     'CSVDelimiterChoices',
+    'DistanceUnitChoices',
     'ImportFormatChoices',
     'ImportMethodChoices',
+    'WeightUnitChoices',
 )
 
 
@@ -157,3 +159,39 @@ class CSVDelimiterChoices(ChoiceSet):
         (SEMICOLON, _('Semicolon')),
         (TAB, _('Tab')),
     ]
+
+
+class DistanceUnitChoices(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')),
+    )
+
+
+class WeightUnitChoices(ChoiceSet):
+
+    # Metric
+    UNIT_KILOGRAM = 'kg'
+    UNIT_GRAM = 'g'
+
+    # Imperial
+    UNIT_POUND = 'lb'
+    UNIT_OUNCE = 'oz'
+
+    CHOICES = (
+        (UNIT_KILOGRAM, _('Kilograms')),
+        (UNIT_GRAM, _('Grams')),
+        (UNIT_POUND, _('Pounds')),
+        (UNIT_OUNCE, _('Ounces')),
+    )

+ 97 - 0
netbox/netbox/models/mixins.py

@@ -0,0 +1,97 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from netbox.choices import *
+from utilities.conversion import to_grams, to_meters
+
+__all__ = (
+    'DistanceMixin',
+    'WeightMixin',
+)
+
+
+class WeightMixin(models.Model):
+    weight = models.DecimalField(
+        verbose_name=_('weight'),
+        max_digits=8,
+        decimal_places=2,
+        blank=True,
+        null=True
+    )
+    weight_unit = models.CharField(
+        verbose_name=_('weight unit'),
+        max_length=50,
+        choices=WeightUnitChoices,
+        blank=True,
+    )
+    # Stores the normalized weight (in grams) for database ordering
+    _abs_weight = models.PositiveBigIntegerField(
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    def save(self, *args, **kwargs):
+
+        # Store the given weight (if any) in grams for use in database ordering
+        if self.weight and self.weight_unit:
+            self._abs_weight = to_grams(self.weight, self.weight_unit)
+        else:
+            self._abs_weight = None
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate weight and weight_unit
+        if self.weight and not self.weight_unit:
+            raise ValidationError(_("Must specify a unit when setting a weight"))
+
+
+class DistanceMixin(models.Model):
+    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=DistanceUnitChoices,
+        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
+    )
+
+    class Meta:
+        abstract = True
+
+    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 = ''
+
+        super().save(*args, **kwargs)
+
+    def clean(self):
+        super().clean()
+
+        # Validate distance and distance_unit
+        if self.distance and not self.distance_unit:
+            raise ValidationError(_("Must specify a unit when setting a distance"))

+ 14 - 0
netbox/netbox/tables/columns.py

@@ -35,6 +35,7 @@ __all__ = (
     'ContentTypesColumn',
     'CustomFieldColumn',
     'CustomLinkColumn',
+    'DistanceColumn',
     'DurationColumn',
     'LinkedCountColumn',
     'MarkdownColumn',
@@ -691,3 +692,16 @@ class ChoicesColumn(tables.Column):
             value.append(f'({omitted_count} more)')
 
         return ', '.join(value)
+
+
+class DistanceColumn(TemplateColumn):
+    """
+    Distance with template code for formatting
+    """
+    template_code = """
+    {% load helpers %}
+    {% if record.distance %}{{ record.distance|floatformat:"-2" }} {{ record.distance_unit }}{% endif %}
+    """
+
+    def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
+        super().__init__(template_code=template_code, order_by=order_by, **kwargs)

+ 10 - 0
netbox/templates/circuits/circuit.html

@@ -34,6 +34,16 @@
             <th scope="row">{% trans "Status" %}</th>
             <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Distance" %}</th>
+            <td>
+              {% if object.distance is not None %}
+                {{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
+              {% else %}
+                {{ ''|placeholder }}
+              {% endif %}
+            </td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Tenant" %}</th>
             <td>

+ 2 - 1
netbox/utilities/conversion.py

@@ -2,7 +2,8 @@ from decimal import Decimal
 
 from django.utils.translation import gettext as _
 
-from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
+from dcim.choices import CableLengthUnitChoices
+from netbox.choices import WeightUnitChoices
 
 __all__ = (
     'to_grams',

+ 2 - 1
netbox/wireless/api/serializers_/wirelesslinks.py

@@ -4,6 +4,7 @@ from dcim.api.serializers_.device_components import InterfaceSerializer
 from dcim.choices import LinkStatusChoices
 from netbox.api.fields import ChoiceField
 from netbox.api.serializers import NetBoxModelSerializer
+from netbox.choices import *
 from tenancy.api.serializers_.tenants import TenantSerializer
 from wireless.choices import *
 from wireless.models import WirelessLink
@@ -20,7 +21,7 @@ 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)
+    distance_unit = ChoiceField(choices=DistanceUnitChoices, allow_blank=True, required=False, allow_null=True)
 
     class Meta:
         model = WirelessLink

+ 0 - 18
netbox/wireless/choices.py

@@ -481,21 +481,3 @@ 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')),
-    )

+ 2 - 1
netbox/wireless/forms/bulk_edit.py

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import LinkStatusChoices
 from ipam.models import VLAN
+from netbox.choices import *
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
@@ -132,7 +133,7 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
     )
     distance_unit = forms.ChoiceField(
         label=_('Distance unit'),
-        choices=add_blank_choice(WirelessLinkDistanceUnitChoices),
+        choices=add_blank_choice(DistanceUnitChoices),
         required=False,
         initial=''
     )

+ 2 - 1
netbox/wireless/forms/bulk_import.py

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import LinkStatusChoices
 from dcim.models import Interface
 from ipam.models import VLAN
+from netbox.choices import *
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@@ -114,7 +115,7 @@ class WirelessLinkImportForm(NetBoxModelImportForm):
     )
     distance_unit = CSVChoiceField(
         label=_('Distance unit'),
-        choices=WirelessLinkDistanceUnitChoices,
+        choices=DistanceUnitChoices,
         required=False,
         help_text=_('Distance unit')
     )

+ 2 - 1
netbox/wireless/forms/filtersets.py

@@ -2,6 +2,7 @@ from django import forms
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import LinkStatusChoices
+from netbox.choices import *
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import add_blank_choice
@@ -104,7 +105,7 @@ class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     )
     distance_unit = forms.ChoiceField(
         label=_('Distance unit'),
-        choices=add_blank_choice(WirelessLinkDistanceUnitChoices),
+        choices=add_blank_choice(DistanceUnitChoices),
         required=False
     )
     tag = TagFilterField(model)

+ 2 - 35
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 netbox.models.mixins import DistanceMixin
 from utilities.conversion import to_meters
 from .choices import *
 from .constants import *
@@ -126,7 +127,7 @@ def get_wireless_interface_types():
     return {'type__in': WIRELESS_IFACE_TYPES}
 
 
-class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
+class WirelessLink(WirelessAuthenticationBase, DistanceMixin, PrimaryModel):
     """
     A point-to-point connection between two wireless Interfaces.
     """
@@ -155,26 +156,6 @@ 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,
@@ -222,10 +203,6 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
     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:
             raise ValidationError({
@@ -241,16 +218,6 @@ 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
         self._interface_b_device = self.interface_b.device

+ 0 - 4
netbox/wireless/tables/template_code.py

@@ -1,4 +0,0 @@
-WIRELESS_LINK_DISTANCE = """
-{% load helpers %}
-{% if record.distance %}{{ record.distance|floatformat:"-2" }} {{ record.distance_unit }}{% endif %}
-"""

+ 1 - 5
netbox/wireless/tables/wirelesslink.py

@@ -4,7 +4,6 @@ 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',
@@ -37,10 +36,7 @@ class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name=_('Interface B'),
         linkify=True
     )
-    distance = columns.TemplateColumn(
-        template_code=WIRELESS_LINK_DISTANCE,
-        order_by=('_abs_distance')
-    )
+    distance = columns.DistanceColumn()
     tags = columns.TagColumn(
         url_name='wireless:wirelesslink_list'
     )

+ 5 - 4
netbox/wireless/tests/test_filtersets.py

@@ -3,6 +3,7 @@ from django.test import TestCase
 from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
 from dcim.models import Interface
 from ipam.models import VLAN
+from netbox.choices import DistanceUnitChoices
 from tenancy.models import Tenant
 from wireless.choices import *
 from wireless.filtersets import *
@@ -261,7 +262,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
             auth_psk='PSK1',
             tenant=tenants[0],
             distance=10,
-            distance_unit=WirelessLinkDistanceUnitChoices.UNIT_FOOT,
+            distance_unit=DistanceUnitChoices.UNIT_FOOT,
             description='foobar1'
         ).save()
         WirelessLink(
@@ -274,7 +275,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
             auth_psk='PSK2',
             tenant=tenants[1],
             distance=20,
-            distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER,
+            distance_unit=DistanceUnitChoices.UNIT_METER,
             description='foobar2'
         ).save()
         WirelessLink(
@@ -286,7 +287,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
             auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
             auth_psk='PSK3',
             distance=30,
-            distance_unit=WirelessLinkDistanceUnitChoices.UNIT_METER,
+            distance_unit=DistanceUnitChoices.UNIT_METER,
             tenant=tenants[2],
         ).save()
         WirelessLink(
@@ -324,7 +325,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_distance_unit(self):
-        params = {'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT}
+        params = {'distance_unit': DistanceUnitChoices.UNIT_FOOT}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_description(self):

+ 3 - 2
netbox/wireless/tests/test_views.py

@@ -2,6 +2,7 @@ from wireless.choices import *
 from wireless.models import *
 from dcim.choices import InterfaceTypeChoices, LinkStatusChoices
 from dcim.models import Interface
+from netbox.choices import DistanceUnitChoices
 from tenancy.models import Tenant
 from utilities.testing import ViewTestCases, create_tags, create_test_device
 
@@ -161,7 +162,7 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'interface_b': interfaces[7].pk,
             'status': LinkStatusChoices.STATUS_PLANNED,
             'distance': 100,
-            'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_FOOT,
+            'distance_unit': DistanceUnitChoices.UNIT_FOOT,
             'tenant': tenants[1].pk,
             'tags': [t.pk for t in tags],
         }
@@ -183,5 +184,5 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.bulk_edit_data = {
             'status': LinkStatusChoices.STATUS_PLANNED,
             'distance': 50,
-            'distance_unit': WirelessLinkDistanceUnitChoices.UNIT_METER,
+            'distance_unit': DistanceUnitChoices.UNIT_METER,
         }