diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index b3f585bee..c80d5b8a1 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 598237e76..573cd68fe 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 c5646cf2b..be35a24c5 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 000000000..e20a8b8aa --- /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 28d21ff68..2b8f05206 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 c2cebe163..ab9cbe9f3 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 324c4de03..203cdc3fb 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 5ab4994ea..63165d8d2 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 6a3753859..eed92dffd 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 000000000..332066b04 --- /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 2490a0c5a..cf469c930 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 0bbaddb52..e0f63a5ca 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 f43a44c62..e668215ad 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 585b75686..56566dcd7 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 71edae573..0d79bca25 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 61524d345..af55cd70b 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 43b4910f4..5f8fb01eb 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 bf0039315..36675aa13 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 c4a33911f..7f722c50e 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 6abbec619..78189ec49 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 ce6753877..a14a468b6 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):