Ver Fonte

Merge branch 'feature' of github.com:netbox-community/netbox into feature

checktheroads há 4 anos atrás
pai
commit
7d7d06651c

+ 10 - 0
docs/additional-features/export-templates.md

@@ -33,6 +33,16 @@ The `as_attachment` attribute of an export template controls its behavior when r
 
 
 A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
 A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
 
 
+## REST API Integration
+
+When it is necessary to provide authentication credentials (such as when [`LOGIN_REQUIRED`](../configuration/optional-settings.md#login_required) has been enabled), it is recommended to render export templates via the REST API. This allows the client to specify an authentication token. To render an export template via the REST API, make a `GET` request to the model's list endpoint and append the `export` parameter specifying the export template name. For example:
+
+```
+GET /api/dcim/sites/?export=MyTemplateName
+```
+
+Note that the body of the response will contain only the rendered export template content, as opposed to a JSON object or list.
+
 ## Example
 ## Example
 
 
 Here's an example device export template that will generate a simple Nagios configuration from a list of devices.
 Here's an example device export template that will generate a simple Nagios configuration from a list of devices.

+ 16 - 0
docs/release-notes/version-2.12.md

@@ -2,22 +2,38 @@
 
 
 ## v2.12-beta1 (FUTURE)
 ## v2.12-beta1 (FUTURE)
 
 
+### Enhancements
+
+* [#3665](https://github.com/netbox-community/netbox/issues/3665) - Enable rendering export templates via REST API
+* [#4609](https://github.com/netbox-community/netbox/issues/4609) - Allow marking prefixes as fully utilized
+* [#5806](https://github.com/netbox-community/netbox/issues/5806) - Add kilometer and mile as choices for cable length unit
+* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
+
 ### Other Changes
 ### Other Changes
 
 
 * [#5532](https://github.com/netbox-community/netbox/issues/5532) - Drop support for Python 3.6
 * [#5532](https://github.com/netbox-community/netbox/issues/5532) - Drop support for Python 3.6
 * [#5994](https://github.com/netbox-community/netbox/issues/5994) - Drop support for `display_field` argument on ObjectVar
 * [#5994](https://github.com/netbox-community/netbox/issues/5994) - Drop support for `display_field` argument on ObjectVar
+* [#6338](https://github.com/netbox-community/netbox/issues/6338) - Decimal fields are no longer coerced to strings in REST API
 
 
 ### REST API Changes
 ### REST API Changes
 
 
+* dcim.Cable
+    * `length` is now a decimal value
 * dcim.Device
 * dcim.Device
     * Removed the `display_name` attribute (use `display` instead)
     * Removed the `display_name` attribute (use `display` instead)
 * dcim.DeviceType
 * dcim.DeviceType
     * Removed the `display_name` attribute (use `display` instead)
     * Removed the `display_name` attribute (use `display` instead)
 * dcim.Rack
 * dcim.Rack
     * Removed the `display_name` attribute (use `display` instead)
     * Removed the `display_name` attribute (use `display` instead)
+* dcim.Site
+    * `latitude` and `longitude` are now decimal fields rather than strings
 * extras.ContentType
 * extras.ContentType
     * Removed the `display_name` attribute (use `display` instead)
     * Removed the `display_name` attribute (use `display` instead)
+* ipam.Prefix
+    * Added the `mark_utilized` boolean field
 * ipam.VLAN
 * ipam.VLAN
     * Removed the `display_name` attribute (use `display` instead)
     * Removed the `display_name` attribute (use `display` instead)
 * ipam.VRF
 * ipam.VRF
     * Removed the `display_name` attribute (use `display` instead)
     * Removed the `display_name` attribute (use `display` instead)
+* virtualization.VirtualMachine
+    * `vcpus` is now a decimal field rather than a string

+ 7 - 0
netbox/dcim/choices.py

@@ -1064,14 +1064,21 @@ class CableStatusChoices(ChoiceSet):
 
 
 class CableLengthUnitChoices(ChoiceSet):
 class CableLengthUnitChoices(ChoiceSet):
 
 
+    # Metric
+    UNIT_KILOMETER = 'km'
     UNIT_METER = 'm'
     UNIT_METER = 'm'
     UNIT_CENTIMETER = 'cm'
     UNIT_CENTIMETER = 'cm'
+
+    # Imperial
+    UNIT_MILE = 'mi'
     UNIT_FOOT = 'ft'
     UNIT_FOOT = 'ft'
     UNIT_INCH = 'in'
     UNIT_INCH = 'in'
 
 
     CHOICES = (
     CHOICES = (
+        (UNIT_KILOMETER, 'Kilometers'),
         (UNIT_METER, 'Meters'),
         (UNIT_METER, 'Meters'),
         (UNIT_CENTIMETER, 'Centimeters'),
         (UNIT_CENTIMETER, 'Centimeters'),
+        (UNIT_MILE, 'Miles'),
         (UNIT_FOOT, 'Feet'),
         (UNIT_FOOT, 'Feet'),
         (UNIT_INCH, 'Inches'),
         (UNIT_INCH, 'Inches'),
     )
     )

+ 16 - 0
netbox/dcim/migrations/0132_cable_length.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0131_consoleport_speed'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='cable',
+            name='length',
+            field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
+        ),
+    ]

+ 3 - 1
netbox/dcim/models/cables.py

@@ -74,7 +74,9 @@ class Cable(PrimaryModel):
     color = ColorField(
     color = ColorField(
         blank=True
         blank=True
     )
     )
-    length = models.PositiveSmallIntegerField(
+    length = models.DecimalField(
+        max_digits=8,
+        decimal_places=2,
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )

+ 4 - 5
netbox/extras/models/models.py

@@ -300,13 +300,12 @@ class ExportTemplate(BigIDModel):
 
 
         # Build the response
         # Build the response
         response = HttpResponse(output, content_type=mime_type)
         response = HttpResponse(output, content_type=mime_type)
-        filename = 'netbox_{}{}'.format(
-            queryset.model._meta.verbose_name_plural,
-            '.{}'.format(self.file_extension) if self.file_extension else ''
-        )
 
 
         if self.as_attachment:
         if self.as_attachment:
-            response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+            basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
+            extension = f'.{self.file_extension}' if self.file_extension else ''
+            filename = f'netbox_{basename}{extension}'
+            response['Content-Disposition'] = f'attachment; filename="{filename}"'
 
 
         return response
         return response
 
 

+ 1 - 1
netbox/ipam/api/serializers.py

@@ -202,7 +202,7 @@ class PrefixSerializer(PrimaryModelSerializer):
         model = Prefix
         model = Prefix
         fields = [
         fields = [
             'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
             'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
-            'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'mark_utilized', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         read_only_fields = ['family']
         read_only_fields = ['family']
 
 

+ 1 - 1
netbox/ipam/filtersets.py

@@ -304,7 +304,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['id', 'is_pool']
+        fields = ['id', 'is_pool', 'mark_utilized']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 16 - 4
netbox/ipam/forms.py

@@ -454,11 +454,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = [
         fields = [
-            'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant',
-            'tags',
+            'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
+            'tenant_group', 'tenant', 'tags',
         ]
         ]
         fieldsets = (
         fieldsets = (
-            ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'description', 'tags')),
+            ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')),
             ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
             ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
             ('Tenancy', ('tenant_group', 'tenant')),
             ('Tenancy', ('tenant_group', 'tenant')),
         )
         )
@@ -582,6 +582,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         widget=BulkEditNullBooleanSelect(),
         widget=BulkEditNullBooleanSelect(),
         label='Is a pool'
         label='Is a pool'
     )
     )
+    mark_utilized = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect(),
+        label='Treat as 100% utilized'
+    )
     description = forms.CharField(
     description = forms.CharField(
         max_length=100,
         max_length=100,
         required=False
         required=False
@@ -597,7 +602,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
     model = Prefix
     model = Prefix
     field_order = [
     field_order = [
         'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id',
         'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id',
-        'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool',
+        'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool', 'mark_utilized',
     ]
     ]
     mask_length__lte = forms.IntegerField(
     mask_length__lte = forms.IntegerField(
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
@@ -675,6 +680,13 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    mark_utilized = forms.NullBooleanField(
+        required=False,
+        label=_('Marked as 100% utilized'),
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 16 - 0
netbox/ipam/migrations/0047_prefix_mark_utilized.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0046_set_vlangroup_scope_types'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='prefix',
+            name='mark_utilized',
+            field=models.BooleanField(default=False),
+        ),
+    ]

+ 14 - 2
netbox/ipam/models/ip.py

@@ -288,6 +288,10 @@ class Prefix(PrimaryModel):
         default=False,
         default=False,
         help_text='All IP addresses within this prefix are considered usable'
         help_text='All IP addresses within this prefix are considered usable'
     )
     )
+    mark_utilized = models.BooleanField(
+        default=False,
+        help_text="Treat as 100% utilized"
+    )
     description = models.CharField(
     description = models.CharField(
         max_length=200,
         max_length=200,
         blank=True
         blank=True
@@ -296,10 +300,11 @@ class Prefix(PrimaryModel):
     objects = PrefixQuerySet.as_manager()
     objects = PrefixQuerySet.as_manager()
 
 
     csv_headers = [
     csv_headers = [
-        'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description',
+        'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
+        'description',
     ]
     ]
     clone_fields = [
     clone_fields = [
-        'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
+        'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -364,6 +369,7 @@ class Prefix(PrimaryModel):
             self.get_status_display(),
             self.get_status_display(),
             self.role.name if self.role else None,
             self.role.name if self.role else None,
             self.is_pool,
             self.is_pool,
+            self.mark_utilized,
             self.description,
             self.description,
         )
         )
 
 
@@ -422,6 +428,9 @@ class Prefix(PrimaryModel):
         """
         """
         Return all available IPs within this prefix as an IPSet.
         Return all available IPs within this prefix as an IPSet.
         """
         """
+        if self.mark_utilized:
+            return list()
+
         prefix = netaddr.IPSet(self.prefix)
         prefix = netaddr.IPSet(self.prefix)
         child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
         child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
         available_ips = prefix - child_ips
         available_ips = prefix - child_ips
@@ -461,6 +470,9 @@ class Prefix(PrimaryModel):
         Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
         Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
         "container", calculate utilization based on child prefixes. For all others, count child IP addresses.
         "container", calculate utilization based on child prefixes. For all others, count child IP addresses.
         """
         """
+        if self.mark_utilized:
+            return 100
+
         if self.status == PrefixStatusChoices.STATUS_CONTAINER:
         if self.status == PrefixStatusChoices.STATUS_CONTAINER:
             queryset = Prefix.objects.filter(
             queryset = Prefix.objects.filter(
                 prefix__net_contained=str(self.prefix),
                 prefix__net_contained=str(self.prefix),

+ 22 - 3
netbox/ipam/tables.py

@@ -256,6 +256,21 @@ class RoleTable(BaseTable):
 # Prefixes
 # Prefixes
 #
 #
 
 
+class PrefixUtilizationColumn(UtilizationColumn):
+    """
+    Extend UtilizationColumn to allow disabling the warning & danger thresholds for prefixes
+    marked as fully utilized.
+    """
+    template_code = """
+    {% load helpers %}
+    {% if record.pk and record.mark_utilized %}
+      {% utilization_graph value warning_threshold=0 danger_threshold=0 %}
+    {% elif record.pk %}
+      {% utilization_graph value %}
+    {% endif %}
+    """
+
+
 class PrefixTable(BaseTable):
 class PrefixTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     prefix = tables.TemplateColumn(
     prefix = tables.TemplateColumn(
@@ -283,11 +298,15 @@ class PrefixTable(BaseTable):
     is_pool = BooleanColumn(
     is_pool = BooleanColumn(
         verbose_name='Pool'
         verbose_name='Pool'
     )
     )
+    mark_utilized = BooleanColumn(
+        verbose_name='Marked Utilized'
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Prefix
         model = Prefix
         fields = (
         fields = (
-            'pk', 'prefix', 'status', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description',
+            'pk', 'prefix', 'status', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'mark_utilized',
+            'description',
         )
         )
         default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
         default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
         row_attrs = {
         row_attrs = {
@@ -296,7 +315,7 @@ class PrefixTable(BaseTable):
 
 
 
 
 class PrefixDetailTable(PrefixTable):
 class PrefixDetailTable(PrefixTable):
-    utilization = UtilizationColumn(
+    utilization = PrefixUtilizationColumn(
         accessor='get_utilization',
         accessor='get_utilization',
         orderable=False
         orderable=False
     )
     )
@@ -308,7 +327,7 @@ class PrefixDetailTable(PrefixTable):
     class Meta(PrefixTable.Meta):
     class Meta(PrefixTable.Meta):
         fields = (
         fields = (
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'is_pool',
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'is_pool',
-            'description', 'tags',
+            'mark_utilized', 'description', 'tags',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',

+ 8 - 2
netbox/ipam/tests/test_filtersets.py

@@ -389,11 +389,11 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         prefixes = (
         prefixes = (
-            Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True),
+            Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
             Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
             Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
             Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
             Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
             Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
             Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
-            Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True),
+            Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
             Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
             Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
             Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
             Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
             Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
             Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
@@ -417,6 +417,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'is_pool': 'false'}
         params = {'is_pool': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
 
 
+    def test_mark_utilized(self):
+        params = {'mark_utilized': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'mark_utilized': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
     def test_within(self):
     def test_within(self):
         params = {'within': '10.0.0.0/16'}
         params = {'within': '10.0.0.0/16'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

+ 15 - 0
netbox/netbox/api/views.py

@@ -5,9 +5,11 @@ from collections import OrderedDict
 from django import __version__ as DJANGO_VERSION
 from django import __version__ as DJANGO_VERSION
 from django.apps import apps
 from django.apps import apps
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.db import transaction
 from django.db.models import ProtectedError
 from django.db.models import ProtectedError
+from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.response import Response
@@ -16,6 +18,7 @@ from rest_framework.views import APIView
 from rest_framework.viewsets import ModelViewSet as ModelViewSet_
 from rest_framework.viewsets import ModelViewSet as ModelViewSet_
 from rq.worker import Worker
 from rq.worker import Worker
 
 
+from extras.models import ExportTemplate
 from netbox.api import BulkOperationSerializer
 from netbox.api import BulkOperationSerializer
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
@@ -222,6 +225,18 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
             # Check that the instance is matched by the view's queryset
             # Check that the instance is matched by the view's queryset
             self.queryset.get(pk=instance.pk)
             self.queryset.get(pk=instance.pk)
 
 
+    def list(self, request, *args, **kwargs):
+        """
+        Overrides ListModelMixin to allow processing ExportTemplates.
+        """
+        if 'export' in request.GET:
+            content_type = ContentType.objects.get_for_model(self.serializer_class.Meta.model)
+            et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
+            queryset = self.filter_queryset(self.get_queryset())
+            return et.render_to_response(queryset)
+
+        return super().list(request, *args, **kwargs)
+
     def perform_create(self, serializer):
     def perform_create(self, serializer):
         model = self.queryset.model
         model = self.queryset.model
         logger = logging.getLogger('netbox.api.views.ModelViewSet')
         logger = logging.getLogger('netbox.api.views.ModelViewSet')

+ 1 - 0
netbox/netbox/settings.py

@@ -464,6 +464,7 @@ FILTERS_NULL_CHOICE_VALUE = 'null'
 REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0]  # Use major.minor as API version
 REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0]  # Use major.minor as API version
 REST_FRAMEWORK = {
 REST_FRAMEWORK = {
     'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
     'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
+    'COERCE_DECIMAL_TO_STRING': False,
     'DEFAULT_AUTHENTICATION_CLASSES': (
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'rest_framework.authentication.SessionAuthentication',
         'rest_framework.authentication.SessionAuthentication',
         'netbox.api.authentication.TokenAuthentication',
         'netbox.api.authentication.TokenAuthentication',

+ 1 - 1
netbox/templates/dcim/cable.html

@@ -60,7 +60,7 @@
                             <th scope="row">Length</th>
                             <th scope="row">Length</th>
                             <td>
                             <td>
                                 {% if object.length %}
                                 {% if object.length %}
-                                    {{ object.length }} {{ object.get_length_unit_display }}
+                                    {{ object.length|floatformat }} {{ object.get_length_unit_display }}
                                 {% else %}
                                 {% else %}
                                     <span class="text-muted">&mdash;</span>
                                     <span class="text-muted">&mdash;</span>
                                 {% endif %}
                                 {% endif %}

+ 1 - 1
netbox/templates/dcim/trace/cable.html

@@ -10,7 +10,7 @@
         <span class="badge bg-secondary">{{ cable.get_type_display|default:"" }}</span>
         <span class="badge bg-secondary">{{ cable.get_type_display|default:"" }}</span>
     {% endif %}
     {% endif %}
     {% if cable.length %}
     {% if cable.length %}
-        ({{ cable.length }} {{ cable.get_length_unit_display }})<br />
+        ({{ cable.length|floatformat }} {{ cable.get_length_unit_display }})<br />
     {% endif %}
     {% endif %}
     <span class="badge bg-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span><br />
     <span class="badge bg-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span><br />
     {% for tag in cable.tags.all %}
     {% for tag in cable.tags.all %}

+ 11 - 5
netbox/templates/ipam/prefix.html

@@ -7,10 +7,10 @@
     <div class="col col-md-5">
     <div class="col col-md-5">
         <div class="card">
         <div class="card">
             <h5 class="card-header">
             <h5 class="card-header">
-                Prefix
+              Prefix
             </h5>
             </h5>
             <div class="card-body">
             <div class="card-body">
-            <table class="table table-hover attr-table">
+              <table class="table table-hover attr-table">
                 <tr>
                 <tr>
                     <td colspan="2">
                     <td colspan="2">
                     <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
                     <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
@@ -20,7 +20,6 @@
                         <span class="badge bg-info">Not a Pool</span>
                         <span class="badge bg-info">Not a Pool</span>
                     {% endif %}
                     {% endif %}
                     </td>
                     </td>
-
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <th scope="row">Family</th>
                     <th scope="row">Family</th>
@@ -101,9 +100,16 @@
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <th scope="row">Utilization</th>
                     <th scope="row">Utilization</th>
-                    <td>{% utilization_graph object.get_utilization %}</td>
+                    <td>
+                      {% if object.mark_utilized %}
+                        {% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
+                        <small>(Marked fully utilized)</small>
+                      {% else %}
+                        {% utilization_graph object.get_utilization %}
+                      {% endif %}
+                    </td>
                 </tr>
                 </tr>
-            </table>
+              </table>
             </div>
             </div>
         </div>
         </div>
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'inc/custom_fields_panel.html' %}

+ 13 - 37
netbox/templates/utilities/templatetags/utilization_graph.html

@@ -1,42 +1,18 @@
 {% if utilization == 0 %}
 {% if utilization == 0 %}
-<div class="progress align-items-center justify-content-center">
+  <div class="progress align-items-center justify-content-center">
     <span class="w-100 text-center">{{ utilization }}%</span>
     <span class="w-100 text-center">{{ utilization }}%</span>
-</div>
+  </div>
 {% else %}
 {% else %}
-<div class="progress">
-    {% if utilization >= danger_threshold %}
-    <div 
-        aria-valuemin="0"
-        role="progressbar"
-        aria-valuemax="100"
-        class="progress-bar bg-danger"
-        aria-valuenow="{{ utilization }}"
-        style="width: {{ utilization }}%;"
+  <div class="progress">
+    <div
+      role="progressbar"
+      aria-valuemin="0"
+      aria-valuemax="100"
+      aria-valuenow="{{ utilization }}"
+      class="progress-bar {{ bar_class }}"
+      style="min-width: 8%; width: {{ utilization }}%;"
     >
     >
-        {{ utilization }}%
+      {{ utilization }}%
     </div>
     </div>
-    {% elif utilization >= warning_threshold %}
-    <div 
-        aria-valuemin="0"
-        role="progressbar"
-        aria-valuemax="100"
-        aria-valuenow="{{ utilization }}"
-        style="width: {{ utilization }}%;"
-        class="progress-bar bg-warning"
-    >
-        {{ utilization }}%
-    </div>
-    {% else %}
-    <div 
-        aria-valuemin="0"
-        role="progressbar"
-        aria-valuemax="100"
-        class="progress-bar bg-success"
-        aria-valuenow="{{ utilization }}"
-        style="min-width: 8%;width: {{ utilization }}%;"
-    >
-        {{ utilization }}%
-    </div>
-    {% endif %}
-</div>
-{% endif %}
+  </div>
+{% endif %}

+ 9 - 2
netbox/utilities/templatetags/helpers.py

@@ -276,10 +276,17 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
     """
     """
     Display a horizontal bar graph indicating a percentage of utilization.
     Display a horizontal bar graph indicating a percentage of utilization.
     """
     """
+    if danger_threshold and utilization >= danger_threshold:
+        bar_class = 'bg-danger'
+    elif warning_threshold and utilization >= warning_threshold:
+        bar_class = 'bg-warning'
+    elif warning_threshold or danger_threshold:
+        bar_class = 'bg-success'
+    else:
+        bar_class = 'bg-default'
     return {
     return {
         'utilization': utilization,
         'utilization': utilization,
-        'warning_threshold': warning_threshold,
-        'danger_threshold': danger_threshold,
+        'bar_class': bar_class,
     }
     }
 
 
 
 

+ 5 - 1
netbox/utilities/utils.py

@@ -198,15 +198,19 @@ def to_meters(length, unit):
             "Unknown unit {}. Must be one of the following: {}".format(unit, ', '.join(valid_units))
             "Unknown unit {}. Must be one of the following: {}".format(unit, ', '.join(valid_units))
         )
         )
 
 
+    if unit == CableLengthUnitChoices.UNIT_KILOMETER:
+        return length * 1000
     if unit == CableLengthUnitChoices.UNIT_METER:
     if unit == CableLengthUnitChoices.UNIT_METER:
         return length
         return length
     if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
     if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
         return length / 100
         return length / 100
+    if unit == CableLengthUnitChoices.UNIT_MILE:
+        return length * 1609.344
     if unit == CableLengthUnitChoices.UNIT_FOOT:
     if unit == CableLengthUnitChoices.UNIT_FOOT:
         return length * 0.3048
         return length * 0.3048
     if unit == CableLengthUnitChoices.UNIT_INCH:
     if unit == CableLengthUnitChoices.UNIT_INCH:
         return length * 0.3048 * 12
         return length * 0.3048 * 12
-    raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit))
+    raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
 
 
 
 
 def render_jinja2(template_code, context):
 def render_jinja2(template_code, context):