{% trans "Location" %}
diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html
index cd6a5b9f0..0a9d9ce5d 100644
--- a/netbox/templates/dcim/devicetype/base.html
+++ b/netbox/templates/dcim/devicetype/base.html
@@ -25,6 +25,12 @@
{% if perms.dcim.add_poweroutlettemplate %}
{% trans "Power Outlets" %}
{% endif %}
+ {% if perms.dcim.add_coolingintaketemplate %}
+
{% trans "Cooling Intakes" %}
+ {% endif %}
+ {% if perms.dcim.add_coolingoutflowtemplate %}
+
{% trans "Cooling Outflows" %}
+ {% endif %}
{% if perms.dcim.add_interfacetemplate %}
{% trans "Interfaces" %}
{% endif %}
diff --git a/netbox/templates/ui/attrs/measurement.html b/netbox/templates/ui/attrs/measurement.html
new file mode 100644
index 000000000..b3cf9518a
--- /dev/null
+++ b/netbox/templates/ui/attrs/measurement.html
@@ -0,0 +1,8 @@
+{# As numeric.html, but renders the unit verbatim. Attributes which resolve their own unit abbreviation #}
+{# (e.g. DiameterAttr, FlowRateAttr) already supply correct casing, which "L/min" and "GPM" depend on. #}
+
+ {{ value }}
+ {% if unit %}
+ {{ unit }}
+ {% endif %}
+
diff --git a/netbox/utilities/conversion.py b/netbox/utilities/conversion.py
index 36ac072d8..2c0869529 100644
--- a/netbox/utilities/conversion.py
+++ b/netbox/utilities/conversion.py
@@ -3,66 +3,91 @@ from decimal import Decimal, InvalidOperation
from django.utils.translation import gettext as _
from dcim.choices import CableLengthUnitChoices
-from netbox.choices import WeightUnitChoices
+from netbox.choices import (
+ DiameterUnitChoices,
+ FlowRateUnitChoices,
+ WeightUnitChoices,
+)
__all__ = (
'to_grams',
+ 'to_liters_per_minute',
'to_meters',
+ 'to_millimeters',
)
+def _normalized_measurement(value, unit, converters, quantity, precision=4) -> Decimal:
+ """
+ Shared implementation for the unit-normalization helpers below. Coerce `value` to a non-negative
+ Decimal, apply the matching per-unit converter, and round the result to `precision` decimal places.
+ `converters` maps each valid unit to a callable receiving the Decimal value; `quantity` labels the
+ value in error messages. Pass `precision=None` to return the unrounded result.
+ """
+ try:
+ value = Decimal(value)
+ except (InvalidOperation, TypeError, ValueError):
+ raise TypeError(
+ _("Invalid value '{value}' for {quantity} (must be a number)").format(value=value, quantity=quantity)
+ )
+ if value < 0:
+ raise ValueError(_("Invalid value for {quantity}: must be a positive number").format(quantity=quantity))
+ if unit not in converters:
+ raise ValueError(
+ _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
+ unit=unit,
+ valid_units=', '.join(converters)
+ )
+ )
+ result = converters[unit](value)
+ return result if precision is None else round(result, precision)
+
+
def to_grams(weight, unit) -> int:
"""
Convert the given weight to integer grams.
"""
- try:
- if weight < 0:
- raise ValueError(_("Weight must be a positive number"))
- except TypeError:
- raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
-
- if unit == WeightUnitChoices.UNIT_KILOGRAM:
- return int(weight * 1000)
- if unit == WeightUnitChoices.UNIT_GRAM:
- return int(weight)
- if unit == WeightUnitChoices.UNIT_POUND:
- return int(weight * Decimal(453.592))
- if unit == WeightUnitChoices.UNIT_OUNCE:
- return int(weight * Decimal(28.3495))
- raise ValueError(
- _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
- unit=unit,
- valid_units=', '.join(WeightUnitChoices.values())
- )
- )
+ # Rounding is suppressed so that the result is truncated exactly as the caller's int() expects; rounding
+ # first could nudge a value across an integer boundary.
+ return int(_normalized_measurement(weight, unit, {
+ WeightUnitChoices.UNIT_KILOGRAM: lambda v: v * 1000,
+ WeightUnitChoices.UNIT_GRAM: lambda v: v,
+ WeightUnitChoices.UNIT_POUND: lambda v: v * Decimal(453.592),
+ WeightUnitChoices.UNIT_OUNCE: lambda v: v * Decimal(28.3495),
+ }, _('weight'), precision=None))
def to_meters(length, unit) -> Decimal:
"""
Convert the given length to meters, returning a Decimal value.
"""
- try:
- length = Decimal(length)
- except InvalidOperation:
- raise TypeError(_("Invalid value '{length}' for length (must be a number)").format(length=length))
- if length < 0:
- raise ValueError(_("Length must be a positive number"))
+ return _normalized_measurement(length, unit, {
+ CableLengthUnitChoices.UNIT_KILOMETER: lambda v: v * 1000,
+ CableLengthUnitChoices.UNIT_METER: lambda v: v,
+ CableLengthUnitChoices.UNIT_CENTIMETER: lambda v: v / 100,
+ CableLengthUnitChoices.UNIT_MILE: lambda v: v * Decimal(1609.344),
+ CableLengthUnitChoices.UNIT_FOOT: lambda v: v * Decimal(0.3048),
+ CableLengthUnitChoices.UNIT_INCH: lambda v: v * Decimal(0.0254),
+ }, _('length'))
- if unit == CableLengthUnitChoices.UNIT_KILOMETER:
- return round(Decimal(length * 1000), 4)
- if unit == CableLengthUnitChoices.UNIT_METER:
- return round(Decimal(length), 4)
- if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
- return round(Decimal(length / 100), 4)
- if unit == CableLengthUnitChoices.UNIT_MILE:
- return round(length * Decimal(1609.344), 4)
- if unit == CableLengthUnitChoices.UNIT_FOOT:
- return round(length * Decimal(0.3048), 4)
- if unit == CableLengthUnitChoices.UNIT_INCH:
- return round(length * Decimal(0.0254), 4)
- raise ValueError(
- _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
- unit=unit,
- valid_units=', '.join(CableLengthUnitChoices.values())
- )
- )
+
+def to_millimeters(diameter, unit) -> Decimal:
+ """
+ Convert the given diameter to millimeters, returning a Decimal value.
+ """
+ return _normalized_measurement(diameter, unit, {
+ DiameterUnitChoices.UNIT_MILLIMETER: lambda v: v,
+ DiameterUnitChoices.UNIT_CENTIMETER: lambda v: v * 10,
+ DiameterUnitChoices.UNIT_INCH: lambda v: v * Decimal('25.4'),
+ }, _('diameter'))
+
+
+def to_liters_per_minute(flow_rate, unit) -> Decimal:
+ """
+ Convert the given flow rate to liters per minute, returning a Decimal value.
+ """
+ return _normalized_measurement(flow_rate, unit, {
+ FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE: lambda v: v,
+ FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR: lambda v: v * Decimal(1000) / Decimal(60),
+ FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE: lambda v: v * Decimal('3.785411784'),
+ }, _('flow rate'))
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index e2b73190b..9e7d22034 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -10,7 +10,9 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT
from netbox.ui.attrs import (
+ compute_diameter_display,
compute_distance_display,
+ compute_flow_rate_display,
compute_weight_display,
)
from utilities.forms import TableConfigForm, get_selected_values
@@ -21,7 +23,9 @@ __all__ = (
'action_url',
'applied_filters',
'as_range',
+ 'display_diameter',
'display_distance',
+ 'display_flow_rate',
'display_weight',
'divide',
'get_item',
@@ -360,6 +364,30 @@ def display_distance(context, distance, distance_unit, abs_distance):
return f'{value:g} {unit}'
+@register.simple_tag(takes_context=True)
+def display_diameter(context, diameter, diameter_unit, abs_diameter):
+ """
+ Render a diameter value respecting the user's ui.measurement_system preference.
+ """
+ if diameter is None:
+ return ''
+ system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+ value, unit = compute_diameter_display(diameter, diameter_unit, abs_diameter, system)
+ return f'{value:g} {unit}'
+
+
+@register.simple_tag(takes_context=True)
+def display_flow_rate(context, flow_rate, flow_rate_unit, abs_flow_rate):
+ """
+ Render a flow rate value respecting the user's ui.measurement_system preference.
+ """
+ if flow_rate is None:
+ return ''
+ system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
+ value, unit = compute_flow_rate_display(flow_rate, flow_rate_unit, abs_flow_rate, system)
+ return f'{value:g} {unit}'
+
+
@register.filter("startswith")
def startswith(text: str, starts: str) -> bool:
"""
diff --git a/netbox/utilities/tests/test_conversions.py b/netbox/utilities/tests/test_conversions.py
index f84d6499e..855cbd9ca 100644
--- a/netbox/utilities/tests/test_conversions.py
+++ b/netbox/utilities/tests/test_conversions.py
@@ -1,8 +1,17 @@
from decimal import Decimal
from dcim.choices import CableLengthUnitChoices
-from netbox.choices import WeightUnitChoices
-from utilities.conversion import to_grams, to_meters
+from netbox.choices import (
+ DiameterUnitChoices,
+ FlowRateUnitChoices,
+ WeightUnitChoices,
+)
+from utilities.conversion import (
+ to_grams,
+ to_liters_per_minute,
+ to_meters,
+ to_millimeters,
+)
from utilities.testing.base import TestCase
@@ -25,6 +34,34 @@ class ConversionsTestCase(TestCase):
to_grams(1, WeightUnitChoices.UNIT_OUNCE),
28
)
+ # The result is truncated, not rounded
+ self.assertEqual(
+ to_grams(Decimal('1.9999'), WeightUnitChoices.UNIT_GRAM),
+ 1
+ )
+ with self.assertRaises(ValueError):
+ to_grams(1, 'invalid')
+ with self.assertRaises(ValueError):
+ to_grams(-1, WeightUnitChoices.UNIT_GRAM)
+ with self.assertRaises(TypeError):
+ to_grams('abc', WeightUnitChoices.UNIT_GRAM)
+ with self.assertRaises(TypeError):
+ to_grams(None, WeightUnitChoices.UNIT_GRAM)
+
+ def test_invalid_values(self):
+ # A non-numeric value is reported as a TypeError rather than surfacing a raw Decimal error
+ for converter, unit in (
+ (to_meters, CableLengthUnitChoices.UNIT_METER),
+ (to_millimeters, DiameterUnitChoices.UNIT_MILLIMETER),
+ (to_liters_per_minute, FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE),
+ ):
+ with self.subTest(converter=converter.__name__):
+ with self.assertRaises(TypeError):
+ converter(None, unit)
+ with self.assertRaises(TypeError):
+ converter('abc', unit)
+ with self.assertRaises(ValueError):
+ converter(-1, unit)
def test_to_meters(self):
self.assertEqual(
@@ -51,3 +88,37 @@ class ConversionsTestCase(TestCase):
to_meters(1, CableLengthUnitChoices.UNIT_INCH),
Decimal('0.0254')
)
+
+ def test_to_millimeters(self):
+ self.assertEqual(
+ to_millimeters(1, DiameterUnitChoices.UNIT_MILLIMETER),
+ Decimal('1')
+ )
+ self.assertEqual(
+ to_millimeters(1, DiameterUnitChoices.UNIT_CENTIMETER),
+ Decimal('10')
+ )
+ self.assertEqual(
+ to_millimeters(1, DiameterUnitChoices.UNIT_INCH),
+ Decimal('25.4')
+ )
+ with self.assertRaises(ValueError):
+ to_millimeters(1, 'invalid')
+
+ def test_to_liters_per_minute(self):
+ self.assertEqual(
+ to_liters_per_minute(10, FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE),
+ Decimal('10')
+ )
+ self.assertAlmostEqual(
+ to_liters_per_minute(6, FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR),
+ Decimal('100'),
+ places=4
+ )
+ self.assertAlmostEqual(
+ to_liters_per_minute(10, FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE),
+ Decimal('37.8541'),
+ places=4
+ )
+ with self.assertRaises(ValueError):
+ to_liters_per_minute(10, 'invalid')