Fixes #21095: Add IEC unit labels support and rename humanize helpers to be unit-agnostic (#21789)

This commit is contained in:
Jonathan Senecal
2026-04-02 17:30:49 -04:00
committed by GitHub
parent 40eec679d9
commit a19daa5466
12 changed files with 153 additions and 36 deletions

View File

@@ -12,7 +12,7 @@
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th> <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td> <td>
{% if memory_sum %} {% if memory_sum %}
<span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span> <span title={{ memory_sum }}>{{ memory_sum|humanize_ram_capacity }}</span>
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
{% endif %} {% endif %}
@@ -24,7 +24,7 @@
</th> </th>
<td> <td>
{% if disk_sum %} {% if disk_sum %}
{{ disk_sum|humanize_disk_megabytes }} {{ disk_sum|humanize_disk_capacity }}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
{% endif %} {% endif %}

View File

@@ -12,7 +12,7 @@
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th> <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td> <td>
{% if object.memory %} {% if object.memory %}
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span> <span title={{ object.memory }}>{{ object.memory|humanize_ram_capacity }}</span>
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
{% endif %} {% endif %}
@@ -24,7 +24,7 @@
</th> </th>
<td> <td>
{% if object.disk %} {% if object.disk %}
{{ object.disk|humanize_disk_megabytes }} {{ object.disk|humanize_disk_capacity }}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
{% endif %} {% endif %}

View File

@@ -1,2 +1,2 @@
{% load helpers %} {% load helpers %}
{{ value|humanize_disk_megabytes }} {{ value|humanize_disk_capacity }}

View File

@@ -14,6 +14,7 @@ __all__ = (
'expand_alphanumeric_pattern', 'expand_alphanumeric_pattern',
'expand_ipaddress_pattern', 'expand_ipaddress_pattern',
'form_from_model', 'form_from_model',
'get_capacity_unit_label',
'get_field_value', 'get_field_value',
'get_selected_values', 'get_selected_values',
'parse_alphanumeric_range', '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]) 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): def get_field_value(form, field_name):
""" """
Return the current bound or initial value associated with a form field, prior to calling Return the current bound or initial value associated with a form field, prior to calling

View File

@@ -20,8 +20,8 @@ __all__ = (
'divide', 'divide',
'get_item', 'get_item',
'get_key', 'get_key',
'humanize_disk_megabytes', 'humanize_disk_capacity',
'humanize_ram_megabytes', 'humanize_ram_capacity',
'humanize_speed', 'humanize_speed',
'icon_from_status', 'icon_from_status',
'kg_to_pounds', 'kg_to_pounds',
@@ -208,42 +208,52 @@ def humanize_speed(speed):
return '{} Kbps'.format(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 "" return ""
if divisor == 1024:
labels = ('MiB', 'GiB', 'TiB', 'PiB')
else:
labels = ('MB', 'GB', 'TB', 'PB')
PB_SIZE = divisor**3 PB_SIZE = divisor**3
TB_SIZE = divisor**2 TB_SIZE = divisor**2
GB_SIZE = divisor GB_SIZE = divisor
if mb >= PB_SIZE: if value >= PB_SIZE:
return f"{mb / PB_SIZE:.2f} PB" return f"{value / PB_SIZE:.2f} {labels[3]}"
if mb >= TB_SIZE: if value >= TB_SIZE:
return f"{mb / TB_SIZE:.2f} TB" return f"{value / TB_SIZE:.2f} {labels[2]}"
if mb >= GB_SIZE: if value >= GB_SIZE:
return f"{mb / GB_SIZE:.2f} GB" return f"{value / GB_SIZE:.2f} {labels[1]}"
return f"{mb} MB" return f"{value} {labels[0]}"
@register.filter() @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.). Express a disk capacity in the most suitable unit, using the DISK_BASE_UNIT
Use the DISK_BASE_UNIT setting to determine the divisor. Default is 1000. 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() @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.). Express a RAM capacity in the most suitable unit, using the RAM_BASE_UNIT
Use the RAM_BASE_UNIT setting to determine the divisor. Default is 1000. 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() @register.filter()

View File

@@ -6,7 +6,12 @@ from netbox.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.fields.csv import CSVSelectWidget from utilities.forms.fields.csv import CSVSelectWidget
from utilities.forms.forms import BulkRenameForm 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 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[0][1], [(2, 'Option 2')])
self.assertEqual(widget.choices[1][0], 'Group B') self.assertEqual(widget.choices[1][0], 'Group B')
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')]) 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')

View File

@@ -3,6 +3,7 @@ from unittest.mock import patch
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from utilities.templatetags.builtins.tags import static_with_params from utilities.templatetags.builtins.tags import static_with_params
from utilities.templatetags.helpers import _humanize_capacity
class StaticWithParamsTest(TestCase): class StaticWithParamsTest(TestCase):
@@ -46,3 +47,46 @@ class StaticWithParamsTest(TestCase):
# Check that new parameter value is used # Check that new parameter value is used
self.assertIn('v=new_version', result) self.assertIn('v=new_version', result)
self.assertNotIn('v=old_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')

View File

@@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import InterfaceModeChoices 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 import BulkRenameForm, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.utils import get_capacity_unit_label
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import * from virtualization.models import *
@@ -138,11 +140,11 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
) )
memory = forms.IntegerField( memory = forms.IntegerField(
required=False, required=False,
label=_('Memory (MB)') label=_('Memory')
) )
disk = forms.IntegerField( disk = forms.IntegerField(
required=False, required=False,
label=_('Disk (MB)') label=_('Disk')
) )
config_template = DynamicModelChoiceField( config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
@@ -159,6 +161,13 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', '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): class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
virtual_machine = forms.ModelChoiceField( virtual_machine = forms.ModelChoiceField(
@@ -304,7 +313,7 @@ class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
) )
size = forms.IntegerField( size = forms.IntegerField(
required=False, required=False,
label=_('Size (MB)') label=_('Size')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'), label=_('Description'),
@@ -318,6 +327,12 @@ class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
) )
nullable_fields = ('description',) 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): class VirtualDiskBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(

View File

@@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import * 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 import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.utils import get_capacity_unit_label
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import * from virtualization.models import *
from vpn.models import L2VPN from vpn.models import L2VPN
@@ -281,8 +283,14 @@ class VirtualDiskFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
label=_('Virtual machine') label=_('Virtual machine')
) )
size = forms.IntegerField( size = forms.IntegerField(
label=_('Size (MB)'), label=_('Size'),
required=False, required=False,
min_value=1 min_value=1
) )
tag = TagFilterField(model) 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))

View File

@@ -1,5 +1,6 @@
from django import forms from django import forms
from django.apps import apps from django.apps import apps
from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ 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 import ConfirmationForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.utils import get_capacity_unit_label
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
from virtualization.models import * from virtualization.models import *
@@ -236,6 +238,10 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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: if self.instance.pk:
# Disable the disk field if one or more VirtualDisks have been created # Disable the disk field if one or more VirtualDisks have been created
@@ -401,3 +407,9 @@ class VirtualDiskForm(VMComponentForm):
fields = [ fields = [
'virtual_machine', 'name', 'size', 'description', 'owner', 'tags', '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))

View File

@@ -121,12 +121,12 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
memory = models.PositiveIntegerField( memory = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
verbose_name=_('memory (MB)') verbose_name=_('memory')
) )
disk = models.PositiveIntegerField( disk = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
verbose_name=_('disk (MB)') verbose_name=_('disk')
) )
serial = models.CharField( serial = models.CharField(
verbose_name=_('serial number'), verbose_name=_('serial number'),
@@ -425,7 +425,7 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
class VirtualDisk(ComponentModel, TrackingModelMixin): class VirtualDisk(ComponentModel, TrackingModelMixin):
size = models.PositiveIntegerField( size = models.PositiveIntegerField(
verbose_name=_('size (MB)'), verbose_name=_('size'),
) )
class Meta(ComponentModel.Meta): class Meta(ComponentModel.Meta):

View File

@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.tables.devices import BaseInterfaceTable from dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, PrimaryModelTable, columns from netbox.tables import NetBoxTable, PrimaryModelTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin 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 virtualization.models import VirtualDisk, VirtualMachine, VMInterface
from .template_code import * 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', '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): 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): def render_size(self, value):
return humanize_disk_megabytes(value) return humanize_disk_capacity(value)
class VirtualMachineVirtualDiskTable(VirtualDiskTable): class VirtualMachineVirtualDiskTable(VirtualDiskTable):