From a19daa54664d3dd8b28fca67c9369f36aa6be453 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 2 Apr 2026 17:30:49 -0400 Subject: [PATCH] Fixes #21095: Add IEC unit labels support and rename humanize helpers to be unit-agnostic (#21789) --- .../panels/cluster_resources.html | 4 +- .../panels/virtual_machine_resources.html | 4 +- .../virtualdisk/attrs/size.html | 2 +- netbox/utilities/forms/utils.py | 8 +++ netbox/utilities/templatetags/helpers.py | 50 +++++++++++-------- netbox/utilities/tests/test_forms.py | 19 ++++++- netbox/utilities/tests/test_templatetags.py | 44 ++++++++++++++++ netbox/virtualization/forms/bulk_edit.py | 21 ++++++-- netbox/virtualization/forms/filtersets.py | 10 +++- netbox/virtualization/forms/model_forms.py | 12 +++++ .../virtualization/models/virtualmachines.py | 6 +-- .../virtualization/tables/virtualmachines.py | 9 ++-- 12 files changed, 153 insertions(+), 36 deletions(-) diff --git a/netbox/templates/virtualization/panels/cluster_resources.html b/netbox/templates/virtualization/panels/cluster_resources.html index bdaec3d89..6b5b4da4a 100644 --- a/netbox/templates/virtualization/panels/cluster_resources.html +++ b/netbox/templates/virtualization/panels/cluster_resources.html @@ -12,7 +12,7 @@ {% trans "Memory" %} {% if memory_sum %} - {{ memory_sum|humanize_ram_megabytes }} + {{ memory_sum|humanize_ram_capacity }} {% else %} {{ ''|placeholder }} {% endif %} @@ -24,7 +24,7 @@ {% if disk_sum %} - {{ disk_sum|humanize_disk_megabytes }} + {{ disk_sum|humanize_disk_capacity }} {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/virtualization/panels/virtual_machine_resources.html b/netbox/templates/virtualization/panels/virtual_machine_resources.html index b0ad7c07e..f944a87e7 100644 --- a/netbox/templates/virtualization/panels/virtual_machine_resources.html +++ b/netbox/templates/virtualization/panels/virtual_machine_resources.html @@ -12,7 +12,7 @@ {% trans "Memory" %} {% if object.memory %} - {{ object.memory|humanize_ram_megabytes }} + {{ object.memory|humanize_ram_capacity }} {% else %} {{ ''|placeholder }} {% endif %} @@ -24,7 +24,7 @@ {% if object.disk %} - {{ object.disk|humanize_disk_megabytes }} + {{ object.disk|humanize_disk_capacity }} {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/virtualization/virtualdisk/attrs/size.html b/netbox/templates/virtualization/virtualdisk/attrs/size.html index 1185dbc20..2fc58919b 100644 --- a/netbox/templates/virtualization/virtualdisk/attrs/size.html +++ b/netbox/templates/virtualization/virtualdisk/attrs/size.html @@ -1,2 +1,2 @@ {% load helpers %} -{{ value|humanize_disk_megabytes }} +{{ value|humanize_disk_capacity }} diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 1ed13cb9c..77a0f230b 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -14,6 +14,7 @@ __all__ = ( 'expand_alphanumeric_pattern', 'expand_ipaddress_pattern', 'form_from_model', + 'get_capacity_unit_label', 'get_field_value', 'get_selected_values', 'parse_alphanumeric_range', @@ -130,6 +131,13 @@ def expand_ipaddress_pattern(string, family): yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant]) +def get_capacity_unit_label(divisor=1000): + """ + Return the appropriate base unit label: 'MiB' for binary (1024), 'MB' for decimal (1000). + """ + return 'MiB' if divisor == 1024 else 'MB' + + def get_field_value(form, field_name): """ Return the current bound or initial value associated with a form field, prior to calling diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 0a77b9cab..fbe2d9d12 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -20,8 +20,8 @@ __all__ = ( 'divide', 'get_item', 'get_key', - 'humanize_disk_megabytes', - 'humanize_ram_megabytes', + 'humanize_disk_capacity', + 'humanize_ram_capacity', 'humanize_speed', 'icon_from_status', 'kg_to_pounds', @@ -208,42 +208,52 @@ def humanize_speed(speed): return '{} Kbps'.format(speed) -def _humanize_megabytes(mb, divisor=1000): +def _humanize_capacity(value, divisor=1000): """ - Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.). + Express a capacity value in the most suitable unit (e.g. GB, TiB, etc.). + + The value is treated as a unitless base-unit quantity; the divisor determines + both the scaling thresholds and the label convention: + - 1000: SI labels (MB, GB, TB, PB) + - 1024: IEC labels (MiB, GiB, TiB, PiB) """ - if not mb: + if not value: return "" + if divisor == 1024: + labels = ('MiB', 'GiB', 'TiB', 'PiB') + else: + labels = ('MB', 'GB', 'TB', 'PB') + PB_SIZE = divisor**3 TB_SIZE = divisor**2 GB_SIZE = divisor - if mb >= PB_SIZE: - return f"{mb / PB_SIZE:.2f} PB" - if mb >= TB_SIZE: - return f"{mb / TB_SIZE:.2f} TB" - if mb >= GB_SIZE: - return f"{mb / GB_SIZE:.2f} GB" - return f"{mb} MB" + if value >= PB_SIZE: + return f"{value / PB_SIZE:.2f} {labels[3]}" + if value >= TB_SIZE: + return f"{value / TB_SIZE:.2f} {labels[2]}" + if value >= GB_SIZE: + return f"{value / GB_SIZE:.2f} {labels[1]}" + return f"{value} {labels[0]}" @register.filter() -def humanize_disk_megabytes(mb): +def humanize_disk_capacity(value): """ - Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.). - Use the DISK_BASE_UNIT setting to determine the divisor. Default is 1000. + Express a disk capacity in the most suitable unit, using the DISK_BASE_UNIT + setting to select SI (MB/GB) or IEC (MiB/GiB) labels. """ - return _humanize_megabytes(mb, DISK_BASE_UNIT) + return _humanize_capacity(value, DISK_BASE_UNIT) @register.filter() -def humanize_ram_megabytes(mb): +def humanize_ram_capacity(value): """ - Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.). - Use the RAM_BASE_UNIT setting to determine the divisor. Default is 1000. + Express a RAM capacity in the most suitable unit, using the RAM_BASE_UNIT + setting to select SI (MB/GB) or IEC (MiB/GiB) labels. """ - return _humanize_megabytes(mb, RAM_BASE_UNIT) + return _humanize_capacity(value, RAM_BASE_UNIT) @register.filter() diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 2224cc195..1521373a9 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -6,7 +6,12 @@ from netbox.choices import ImportFormatChoices from utilities.forms.bulk_import import BulkImportForm from utilities.forms.fields.csv import CSVSelectWidget from utilities.forms.forms import BulkRenameForm -from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, get_field_value +from utilities.forms.utils import ( + expand_alphanumeric_pattern, + expand_ipaddress_pattern, + get_capacity_unit_label, + get_field_value, +) from utilities.forms.widgets.select import AvailableOptions, SelectedOptions @@ -550,3 +555,15 @@ class SelectMultipleWidgetTest(TestCase): self.assertEqual(widget.choices[0][1], [(2, 'Option 2')]) self.assertEqual(widget.choices[1][0], 'Group B') self.assertEqual(widget.choices[1][1], [(3, 'Option 3')]) + + +class GetCapacityUnitLabelTest(TestCase): + """ + Test the get_capacity_unit_label function for correct base unit label. + """ + + def test_si_label(self): + self.assertEqual(get_capacity_unit_label(1000), 'MB') + + def test_iec_label(self): + self.assertEqual(get_capacity_unit_label(1024), 'MiB') diff --git a/netbox/utilities/tests/test_templatetags.py b/netbox/utilities/tests/test_templatetags.py index 876eed215..570e2595f 100644 --- a/netbox/utilities/tests/test_templatetags.py +++ b/netbox/utilities/tests/test_templatetags.py @@ -3,6 +3,7 @@ from unittest.mock import patch from django.test import TestCase, override_settings from utilities.templatetags.builtins.tags import static_with_params +from utilities.templatetags.helpers import _humanize_capacity class StaticWithParamsTest(TestCase): @@ -46,3 +47,46 @@ class StaticWithParamsTest(TestCase): # Check that new parameter value is used self.assertIn('v=new_version', result) self.assertNotIn('v=old_version', result) + + +class HumanizeCapacityTest(TestCase): + """ + Test the _humanize_capacity function for correct SI/IEC unit label selection. + """ + + # Tests with divisor=1000 (SI/decimal units) + + def test_si_megabytes(self): + self.assertEqual(_humanize_capacity(500, divisor=1000), '500 MB') + + def test_si_gigabytes(self): + self.assertEqual(_humanize_capacity(2000, divisor=1000), '2.00 GB') + + def test_si_terabytes(self): + self.assertEqual(_humanize_capacity(2000000, divisor=1000), '2.00 TB') + + def test_si_petabytes(self): + self.assertEqual(_humanize_capacity(2000000000, divisor=1000), '2.00 PB') + + # Tests with divisor=1024 (IEC/binary units) + + def test_iec_megabytes(self): + self.assertEqual(_humanize_capacity(500, divisor=1024), '500 MiB') + + def test_iec_gigabytes(self): + self.assertEqual(_humanize_capacity(2048, divisor=1024), '2.00 GiB') + + def test_iec_terabytes(self): + self.assertEqual(_humanize_capacity(2097152, divisor=1024), '2.00 TiB') + + def test_iec_petabytes(self): + self.assertEqual(_humanize_capacity(2147483648, divisor=1024), '2.00 PiB') + + # Edge cases + + def test_empty_value(self): + self.assertEqual(_humanize_capacity(0, divisor=1000), '') + self.assertEqual(_humanize_capacity(None, divisor=1000), '') + + def test_default_divisor_is_1000(self): + self.assertEqual(_humanize_capacity(2000), '2.00 GB') diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 1bd463811..f8868d8d3 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices @@ -13,6 +14,7 @@ from tenancy.models import Tenant from utilities.forms import BulkRenameForm, add_blank_choice from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet +from utilities.forms.utils import get_capacity_unit_label from utilities.forms.widgets import BulkEditNullBooleanSelect from virtualization.choices import * from virtualization.models import * @@ -138,11 +140,11 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): ) memory = forms.IntegerField( required=False, - label=_('Memory (MB)') + label=_('Memory') ) disk = forms.IntegerField( required=False, - label=_('Disk (MB)') + label=_('Disk') ) config_template = DynamicModelChoiceField( queryset=ConfigTemplate.objects.all(), @@ -159,6 +161,13 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Set unit labels based on configured RAM_BASE_UNIT / DISK_BASE_UNIT (MB vs MiB) + self.fields['memory'].label = _('Memory ({unit})').format(unit=get_capacity_unit_label(settings.RAM_BASE_UNIT)) + self.fields['disk'].label = _('Disk ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT)) + class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): virtual_machine = forms.ModelChoiceField( @@ -304,7 +313,7 @@ class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): ) size = forms.IntegerField( required=False, - label=_('Size (MB)') + label=_('Size') ) description = forms.CharField( label=_('Description'), @@ -318,6 +327,12 @@ class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): ) nullable_fields = ('description',) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Set unit label based on configured DISK_BASE_UNIT (MB vs MiB) + self.fields['size'].label = _('Size ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT)) + class VirtualDiskBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 5b1b44cb6..94b2a4dd6 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.utils.translation import gettext_lazy as _ from dcim.choices import * @@ -12,6 +13,7 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.rendering import FieldSet +from utilities.forms.utils import get_capacity_unit_label from virtualization.choices import * from virtualization.models import * from vpn.models import L2VPN @@ -281,8 +283,14 @@ class VirtualDiskFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm): label=_('Virtual machine') ) size = forms.IntegerField( - label=_('Size (MB)'), + label=_('Size'), required=False, min_value=1 ) tag = TagFilterField(model) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Set unit label based on configured DISK_BASE_UNIT (MB vs MiB) + self.fields['size'].label = _('Size ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT)) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 99bd49823..85883a6ec 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.apps import apps +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -16,6 +17,7 @@ from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField from utilities.forms.rendering import FieldSet +from utilities.forms.utils import get_capacity_unit_label from utilities.forms.widgets import HTMXSelect from virtualization.models import * @@ -236,6 +238,10 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Set unit labels based on configured RAM_BASE_UNIT / DISK_BASE_UNIT (MB vs MiB) + self.fields['memory'].label = _('Memory ({unit})').format(unit=get_capacity_unit_label(settings.RAM_BASE_UNIT)) + self.fields['disk'].label = _('Disk ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT)) + if self.instance.pk: # Disable the disk field if one or more VirtualDisks have been created @@ -401,3 +407,9 @@ class VirtualDiskForm(VMComponentForm): fields = [ 'virtual_machine', 'name', 'size', 'description', 'owner', 'tags', ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Set unit label based on configured DISK_BASE_UNIT (MB vs MiB) + self.fields['size'].label = _('Size ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT)) diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 5a0f1e9b3..288585552 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -121,12 +121,12 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co memory = models.PositiveIntegerField( blank=True, null=True, - verbose_name=_('memory (MB)') + verbose_name=_('memory') ) disk = models.PositiveIntegerField( blank=True, null=True, - verbose_name=_('disk (MB)') + verbose_name=_('disk') ) serial = models.CharField( verbose_name=_('serial number'), @@ -425,7 +425,7 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): class VirtualDisk(ComponentModel, TrackingModelMixin): size = models.PositiveIntegerField( - verbose_name=_('size (MB)'), + verbose_name=_('size'), ) class Meta(ComponentModel.Meta): diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index d218392c4..819ec6c9c 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.tables.devices import BaseInterfaceTable from netbox.tables import NetBoxTable, PrimaryModelTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin -from utilities.templatetags.helpers import humanize_disk_megabytes +from utilities.templatetags.helpers import humanize_disk_capacity, humanize_ram_capacity from virtualization.models import VirtualDisk, VirtualMachine, VMInterface from .template_code import * @@ -93,8 +93,11 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', ) + def render_memory(self, value): + return humanize_ram_capacity(value) + def render_disk(self, value): - return humanize_disk_megabytes(value) + return humanize_disk_capacity(value) # @@ -184,7 +187,7 @@ class VirtualDiskTable(NetBoxTable): } def render_size(self, value): - return humanize_disk_megabytes(value) + return humanize_disk_capacity(value) class VirtualMachineVirtualDiskTable(VirtualDiskTable):