ソースを参照

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

Jonathan Senecal 4 日 前
コミット
a19daa5466

+ 2 - 2
netbox/templates/virtualization/panels/cluster_resources.html

@@ -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 %}

+ 2 - 2
netbox/templates/virtualization/panels/virtual_machine_resources.html

@@ -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 %}

+ 1 - 1
netbox/templates/virtualization/virtualdisk/attrs/size.html

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

+ 8 - 0
netbox/utilities/forms/utils.py

@@ -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

+ 30 - 20
netbox/utilities/templatetags/helpers.py

@@ -20,8 +20,8 @@ __all__ = (
     'divide',
     'divide',
     'get_item',
     'get_item',
     'get_key',
     'get_key',
-    'humanize_disk_megabytes',
-    'humanize_ram_megabytes',
+    'humanize_disk_capacity',
+    '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:
-        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()
 @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()
 @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()
 @register.filter()

+ 18 - 1
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.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')

+ 44 - 0
netbox/utilities/tests/test_templatetags.py

@@ -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')

+ 18 - 3
netbox/virtualization/forms/bulk_edit.py

@@ -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(

+ 9 - 1
netbox/virtualization/forms/filtersets.py

@@ -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))

+ 12 - 0
netbox/virtualization/forms/model_forms.py

@@ -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))

+ 3 - 3
netbox/virtualization/models/virtualmachines.py

@@ -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):

+ 6 - 3
netbox/virtualization/tables/virtualmachines.py

@@ -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):