diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index b3f585beeec..c80d5b8a1e2 100644 --- a/docs/additional-features/export-templates.md +++ b/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`. +## 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 Here's an example device export template that will generate a simple Nagios configuration from a list of devices. diff --git a/docs/release-notes/version-2.12.md b/docs/release-notes/version-2.12.md index 598237e76fb..573cd68fe8e 100644 --- a/docs/release-notes/version-2.12.md +++ b/docs/release-notes/version-2.12.md @@ -2,22 +2,38 @@ ## 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 * [#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 +* [#6338](https://github.com/netbox-community/netbox/issues/6338) - Decimal fields are no longer coerced to strings in REST API ### REST API Changes +* dcim.Cable + * `length` is now a decimal value * dcim.Device * Removed the `display_name` attribute (use `display` instead) * dcim.DeviceType * Removed the `display_name` attribute (use `display` instead) * dcim.Rack * Removed the `display_name` attribute (use `display` instead) +* dcim.Site + * `latitude` and `longitude` are now decimal fields rather than strings * extras.ContentType * Removed the `display_name` attribute (use `display` instead) +* ipam.Prefix + * Added the `mark_utilized` boolean field * ipam.VLAN * Removed the `display_name` attribute (use `display` instead) * ipam.VRF * Removed the `display_name` attribute (use `display` instead) +* virtualization.VirtualMachine + * `vcpus` is now a decimal field rather than a string diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c5646cf2b3d..be35a24c549 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1064,14 +1064,21 @@ class CableStatusChoices(ChoiceSet): class CableLengthUnitChoices(ChoiceSet): + # Metric + UNIT_KILOMETER = 'km' UNIT_METER = 'm' UNIT_CENTIMETER = 'cm' + + # Imperial + UNIT_MILE = 'mi' UNIT_FOOT = 'ft' UNIT_INCH = 'in' CHOICES = ( + (UNIT_KILOMETER, 'Kilometers'), (UNIT_METER, 'Meters'), (UNIT_CENTIMETER, 'Centimeters'), + (UNIT_MILE, 'Miles'), (UNIT_FOOT, 'Feet'), (UNIT_INCH, 'Inches'), ) diff --git a/netbox/dcim/migrations/0132_cable_length.py b/netbox/dcim/migrations/0132_cable_length.py new file mode 100644 index 00000000000..e20a8b8aaa2 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 28d21ff68fc..2b8f052061f 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -74,7 +74,9 @@ class Cable(PrimaryModel): color = ColorField( blank=True ) - length = models.PositiveSmallIntegerField( + length = models.DecimalField( + max_digits=8, + decimal_places=2, blank=True, null=True ) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index c2cebe1631c..ab9cbe9f39e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -300,13 +300,12 @@ class ExportTemplate(BigIDModel): # Build the response 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: - 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 diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 324c4de03f1..203cdc3fbbe 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -202,7 +202,7 @@ class PrefixSerializer(PrimaryModelSerializer): model = Prefix fields = [ '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'] diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 5ab4994ea6f..63165d8d24c 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -304,7 +304,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class Meta: model = Prefix - fields = ['id', 'is_pool'] + fields = ['id', 'is_pool', 'mark_utilized'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 6a375385902..eed92dffdae 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -454,11 +454,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Prefix 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 = ( - ('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')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -582,6 +582,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF widget=BulkEditNullBooleanSelect(), label='Is a pool' ) + mark_utilized = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Treat as 100% utilized' + ) description = forms.CharField( max_length=100, required=False @@ -597,7 +602,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) model = Prefix field_order = [ '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( widget=forms.HiddenInput() @@ -675,6 +680,13 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) 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) diff --git a/netbox/ipam/migrations/0047_prefix_mark_utilized.py b/netbox/ipam/migrations/0047_prefix_mark_utilized.py new file mode 100644 index 00000000000..332066b0433 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 2490a0c5ae9..cf469c930fe 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -288,6 +288,10 @@ class Prefix(PrimaryModel): default=False, 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( max_length=200, blank=True @@ -296,10 +300,11 @@ class Prefix(PrimaryModel): objects = PrefixQuerySet.as_manager() 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 = [ - 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', + 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', ] class Meta: @@ -364,6 +369,7 @@ class Prefix(PrimaryModel): self.get_status_display(), self.role.name if self.role else None, self.is_pool, + self.mark_utilized, self.description, ) @@ -422,6 +428,9 @@ class Prefix(PrimaryModel): """ Return all available IPs within this prefix as an IPSet. """ + if self.mark_utilized: + return list() + prefix = netaddr.IPSet(self.prefix) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_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 "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: queryset = Prefix.objects.filter( prefix__net_contained=str(self.prefix), diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 0bbaddb5297..e0f63a5cabc 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -256,6 +256,21 @@ class RoleTable(BaseTable): # 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): pk = ToggleColumn() prefix = tables.TemplateColumn( @@ -283,11 +298,15 @@ class PrefixTable(BaseTable): is_pool = BooleanColumn( verbose_name='Pool' ) + mark_utilized = BooleanColumn( + verbose_name='Marked Utilized' + ) class Meta(BaseTable.Meta): model = Prefix 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') row_attrs = { @@ -296,7 +315,7 @@ class PrefixTable(BaseTable): class PrefixDetailTable(PrefixTable): - utilization = UtilizationColumn( + utilization = PrefixUtilizationColumn( accessor='get_utilization', orderable=False ) @@ -308,7 +327,7 @@ class PrefixDetailTable(PrefixTable): class Meta(PrefixTable.Meta): fields = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'is_pool', - 'description', 'tags', + 'mark_utilized', 'description', 'tags', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index f43a44c6289..e668215ad49 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -389,11 +389,11 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) 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.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='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: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), @@ -417,6 +417,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'is_pool': 'false'} 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): params = {'within': '10.0.0.0/16'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 585b75686ee..56566dcd7d3 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -5,9 +5,11 @@ from collections import OrderedDict from django import __version__ as DJANGO_VERSION from django.apps import apps from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError +from django.shortcuts import get_object_or_404 from django_rq.queues import get_connection from rest_framework import status 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 rq.worker import Worker +from extras.models import ExportTemplate from netbox.api import BulkOperationSerializer from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired 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 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): model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 71edae57338..0d79bca256a 100644 --- a/netbox/netbox/settings.py +++ b/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 = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], + 'COERCE_DECIMAL_TO_STRING': False, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', 'netbox.api.authentication.TokenAuthentication', diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 61524d345e4..af55cd70b7f 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -60,7 +60,7 @@ Length {% if object.length %} - {{ object.length }} {{ object.get_length_unit_display }} + {{ object.length|floatformat }} {{ object.get_length_unit_display }} {% else %} {% endif %} diff --git a/netbox/templates/dcim/trace/cable.html b/netbox/templates/dcim/trace/cable.html index 43b4910f476..5f8fb01eb8d 100644 --- a/netbox/templates/dcim/trace/cable.html +++ b/netbox/templates/dcim/trace/cable.html @@ -10,7 +10,7 @@ {{ cable.get_type_display|default:"" }} {% endif %} {% if cable.length %} - ({{ cable.length }} {{ cable.get_length_unit_display }})
+ ({{ cable.length|floatformat }} {{ cable.get_length_unit_display }})
{% endif %} {{ cable.get_status_display }}
{% for tag in cable.tags.all %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index bf003931565..36675aa138e 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -7,10 +7,10 @@
- Prefix + Prefix
- +
- @@ -101,9 +100,16 @@ - + -
{{ object.get_status_display }} @@ -20,7 +20,6 @@ Not a Pool {% endif %}
Family
Utilization{% utilization_graph object.get_utilization %} + {% if object.mark_utilized %} + {% utilization_graph 100 warning_threshold=0 danger_threshold=0 %} + (Marked fully utilized) + {% else %} + {% utilization_graph object.get_utilization %} + {% endif %} +
+
{% include 'inc/custom_fields_panel.html' %} diff --git a/netbox/templates/utilities/templatetags/utilization_graph.html b/netbox/templates/utilities/templatetags/utilization_graph.html index c4a33911f9a..7f722c50e23 100644 --- a/netbox/templates/utilities/templatetags/utilization_graph.html +++ b/netbox/templates/utilities/templatetags/utilization_graph.html @@ -1,42 +1,18 @@ {% if utilization == 0 %} -
+
{{ utilization }}% -
+
{% else %} -
- {% if utilization >= danger_threshold %} -
+
- {{ utilization }}% + {{ utilization }}%
- {% elif utilization >= warning_threshold %} -
- {{ utilization }}% -
- {% else %} -
- {{ utilization }}% -
- {% endif %} -
-{% endif %} \ No newline at end of file +
+{% endif %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 6abbec619ae..78189ec495b 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/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. """ + 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 { 'utilization': utilization, - 'warning_threshold': warning_threshold, - 'danger_threshold': danger_threshold, + 'bar_class': bar_class, } diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index ce6753877e9..a14a468b635 100644 --- a/netbox/utilities/utils.py +++ b/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)) ) + if unit == CableLengthUnitChoices.UNIT_KILOMETER: + return length * 1000 if unit == CableLengthUnitChoices.UNIT_METER: return length if unit == CableLengthUnitChoices.UNIT_CENTIMETER: return length / 100 + if unit == CableLengthUnitChoices.UNIT_MILE: + return length * 1609.344 if unit == CableLengthUnitChoices.UNIT_FOOT: return length * 0.3048 if unit == CableLengthUnitChoices.UNIT_INCH: 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):