Coverage for netbox/ui/attrs.py: 43%
339 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django.template.loader import render_to_string
2from django.utils.safestring import mark_safe
3from django.utils.translation import gettext_lazy as _
5from netbox.config import get_config
6from netbox.ui.utils import build_coords_url, is_coordinate_map_url
7from utilities.data import resolve_attr_path
8from utilities.string import humanize_duration
10__all__ = (
11 'AddressAttr',
12 'ArrayAttr',
13 'BooleanAttr',
14 'ChoiceAttr',
15 'ColorAttr',
16 'DateTimeAttr',
17 'DiameterAttr',
18 'DistanceAttr',
19 'DurationAttr',
20 'FlowRateAttr',
21 'GPSCoordinatesAttr',
22 'GenericForeignKeyAttr',
23 'ImageAttr',
24 'NestedObjectAttr',
25 'NumericAttr',
26 'ObjectAttribute',
27 'RelatedObjectAttr',
28 'RelatedObjectListAttr',
29 'TemplatedAttr',
30 'TextAttr',
31 'TimezoneAttr',
32 'UtilizationAttr',
33 'WeightAttr',
34)
36PLACEHOLDER_HTML = '<span class="text-muted">—</span>'
38IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
41#
42# Mixins
43#
45class MapURLMixin:
46 _map_url = None
48 @property
49 def map_url(self):
50 if self._map_url is True:
51 return get_config().MAPS_URL
52 if self._map_url:
53 return self._map_url
54 return None
57#
58# Attributes
59#
61class ObjectAttribute:
62 """
63 Base class for representing an attribute of an object.
65 Attributes:
66 template_name (str): The name of the template to render
67 placeholder (str): HTML to render for empty/null values
69 Parameters:
70 accessor (str): The dotted path to the attribute being rendered (e.g. "site.region.name")
71 label (str): Human-friendly label for the rendered attribute
72 """
73 template_name = None
74 label = None
75 placeholder = mark_safe(PLACEHOLDER_HTML)
77 def __init__(self, accessor, label=None):
78 self.accessor = accessor
79 if label is not None:
80 self.label = label
82 def get_value(self, obj):
83 """
84 Return the value of the attribute.
86 Parameters:
87 obj (object): The object for which the attribute is being rendered
88 """
89 return resolve_attr_path(obj, self.accessor)
91 def get_context(self, obj, attr, value, context):
92 """
93 Return any additional template context used to render the attribute value.
95 Parameters:
96 obj (object): The object for which the attribute is being rendered
97 attr (str): The name of the attribute being rendered
98 value: The value of the attribute on the object
99 context (dict): The panel template context
100 """
101 return {}
103 def render(self, obj, context):
104 name = context['name']
105 value = self.get_value(obj)
107 # If the value is empty, render a placeholder
108 if value in (None, ''):
109 return self.placeholder
111 return render_to_string(self.template_name, {
112 **self.get_context(obj, name, value, context),
113 'name': name,
114 'value': value,
115 })
118class TextAttr(ObjectAttribute):
119 """
120 A text attribute.
122 Parameters:
123 style (str): CSS class to apply to the rendered attribute
124 format_string (str): If specified, the value will be formatted using this string when rendering
125 copy_button (bool): Set to True to include a copy-to-clipboard button
126 """
127 template_name = 'ui/attrs/text.html'
129 def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs):
130 super().__init__(*args, **kwargs)
131 self.style = style
132 self.format_string = format_string
133 self.copy_button = copy_button
135 def get_value(self, obj):
136 value = resolve_attr_path(obj, self.accessor)
137 # Apply format string (if any)
138 if value is not None and value != '' and self.format_string:
139 return self.format_string.format(value)
140 return value
142 def get_context(self, obj, attr, value, context):
143 return {
144 'style': self.style,
145 'copy_button': self.copy_button,
146 }
149class ArrayAttr(TextAttr):
150 """
151 An attribute comprising an array of values, rendered as a comma-separated list. If specified, `format_string`
152 is applied to each item individually. Null and empty arrays are treated as equivalent: both render as the
153 placeholder.
154 """
156 def get_value(self, obj):
157 value = resolve_attr_path(obj, self.accessor)
158 if not value:
159 return None
160 if self.format_string:
161 return ', '.join(self.format_string.format(v) for v in value)
162 return ', '.join(str(v) for v in value)
165class NumericAttr(ObjectAttribute):
166 """
167 An integer or float attribute.
169 Parameters:
170 unit_accessor (str): Accessor for the unit of measurement to display alongside the value (if any)
171 copy_button (bool): Set to True to include a copy-to-clipboard button
172 """
173 template_name = 'ui/attrs/numeric.html'
175 def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs):
176 super().__init__(*args, **kwargs)
177 self.unit_accessor = unit_accessor
178 self.copy_button = copy_button
180 def get_context(self, obj, attr, value, context):
181 unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
182 return {
183 'unit': unit,
184 'copy_button': self.copy_button,
185 }
188class ChoiceAttr(ObjectAttribute):
189 """
190 A selection from a set of choices.
192 The class calls get_FOO_display() on the terminal object resolved by the accessor
193 to retrieve the human-friendly choice label. For example, accessor="interface.type"
194 will call interface.get_type_display().
195 If a get_FOO_color() method exists on that object, it will be used to render a
196 background color for the attribute value.
197 """
198 template_name = 'ui/attrs/choice.html'
200 def _resolve_target(self, obj):
201 if not self.accessor or '.' not in self.accessor:
202 return obj, self.accessor
204 object_accessor, field_name = self.accessor.rsplit('.', 1)
205 return resolve_attr_path(obj, object_accessor), field_name
207 def get_value(self, obj):
208 target, field_name = self._resolve_target(obj)
209 if target is None:
210 return None
212 display = getattr(target, f'get_{field_name}_display', None)
213 if callable(display):
214 return display()
216 return resolve_attr_path(target, field_name)
218 def get_context(self, obj, attr, value, context):
219 target, field_name = self._resolve_target(obj)
220 if target is None:
221 return {'bg_color': None}
223 get_color = getattr(target, f'get_{field_name}_color', None)
224 bg_color = get_color() if callable(get_color) else None
226 return {
227 'bg_color': bg_color,
228 }
231class BooleanAttr(ObjectAttribute):
232 """
233 A boolean attribute.
235 Parameters:
236 display_false (bool): If False, a placeholder will be rendered instead of the "False" indication
237 """
238 template_name = 'ui/attrs/boolean.html'
240 def __init__(self, *args, display_false=True, **kwargs):
241 super().__init__(*args, **kwargs)
242 self.display_false = display_false
244 def get_value(self, obj):
245 value = super().get_value(obj)
246 if value is False and self.display_false is False:
247 return None
248 return value
251class ColorAttr(ObjectAttribute):
252 """
253 An RGB color value.
254 """
255 template_name = 'ui/attrs/color.html'
256 label = _('Color')
259class ImageAttr(ObjectAttribute):
260 """
261 An attribute representing an image field on the model. Displays the uploaded image.
263 Parameters:
264 load_lazy (bool): If True, the image will be loaded lazily (default: True)
265 decoding (str): Image decoding option ('async', 'sync', 'auto', None)
266 """
267 template_name = 'ui/attrs/image.html'
269 def __init__(self, *args, load_lazy=True, decoding=None, **kwargs):
270 super().__init__(*args, **kwargs)
271 self.load_lazy = load_lazy
273 if decoding is not None and decoding not in IMAGE_DECODING_CHOICES: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 raise ValueError(
275 _('Invalid decoding option: {decoding}! Must be one of {image_decoding_choices}').format(
276 decoding=decoding, image_decoding_choices=', '.join(IMAGE_DECODING_CHOICES)
277 )
278 )
280 # Compute default decoding:
281 # - lazy images: async decoding (performance-friendly hint)
282 # - non-lazy images: omit decoding (browser default/auto)
283 if decoding is None and load_lazy: 283 ↛ 285line 283 didn't jump to line 285 because the condition on line 283 was always true
284 decoding = 'async'
285 self.decoding = decoding
287 def get_context(self, obj, attr, value, context):
288 return {
289 'decoding': self.decoding,
290 'load_lazy': self.load_lazy,
291 }
294class RelatedObjectAttr(ObjectAttribute):
295 """
296 An attribute representing a related object.
298 Parameters:
299 linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
300 grouped_by (str): A second-order object to annotate alongside the related object; for example, an attribute
301 representing the dcim.Site model might specify grouped_by="region"
302 colored (bool): If True, render the object as a colored badge when it exposes a `color` attribute
303 """
304 template_name = 'ui/attrs/object.html'
306 def __init__(self, *args, linkify=None, grouped_by=None, colored=False, **kwargs):
307 super().__init__(*args, **kwargs)
308 self.linkify = linkify
309 self.grouped_by = grouped_by
310 self.colored = colored
312 def get_context(self, obj, attr, value, context):
313 group = getattr(value, self.grouped_by, None) if self.grouped_by else None
314 return {
315 'linkify': self.linkify,
316 'group': group,
317 'colored': self.colored,
318 }
321class RelatedObjectListAttr(RelatedObjectAttr):
322 """
323 An attribute representing a list of related objects.
325 The accessor may resolve to a related manager or queryset.
327 Parameters:
328 max_items (int): Maximum number of items to display
329 overflow_indicator (str | None): Marker rendered as a final list item when
330 additional objects exist beyond `max_items`; set to None to suppress it
331 """
333 template_name = 'ui/attrs/object_list.html'
335 def __init__(self, *args, max_items=None, overflow_indicator='…', **kwargs):
336 super().__init__(*args, **kwargs)
338 if max_items is not None and (type(max_items) is not int or max_items < 1): 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
339 raise ValueError(
340 _('Invalid max_items value: {max_items}! Must be a positive integer or None.').format(
341 max_items=max_items
342 )
343 )
345 self.max_items = max_items
346 self.overflow_indicator = overflow_indicator
348 def _get_items(self, items):
349 """
350 Retrieve items from the given object using the accessor path.
352 Returns a tuple of (items, has_more) where items is a list of resolved objects
353 and has_more indicates whether additional items exist beyond the max_items limit.
354 """
355 if items is None:
356 return [], False
358 if hasattr(items, 'all'):
359 items = items.all()
361 if self.max_items is None:
362 return list(items), False
364 items = list(items[:self.max_items + 1])
365 has_more = len(items) > self.max_items
367 return items[:self.max_items], has_more
369 def get_context(self, obj, attr, value, context):
370 items, has_more = self._get_items(value)
372 return {
373 'linkify': self.linkify,
374 'colored': self.colored,
375 'items': [
376 {
377 'value': item,
378 'group': getattr(item, self.grouped_by, None) if self.grouped_by else None,
379 }
380 for item in items
381 ],
382 'overflow_indicator': self.overflow_indicator if has_more else None,
383 }
385 def render(self, obj, context):
386 name = context['name']
387 value = self.get_value(obj)
388 context_data = self.get_context(obj, name, value, context)
390 if not context_data['items']:
391 return self.placeholder
393 return render_to_string(self.template_name, {
394 'name': name,
395 **context_data,
396 })
399class NestedObjectAttr(ObjectAttribute):
400 """
401 An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the
402 related object in the rendered output.
404 Parameters:
405 linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
406 max_depth (int): Maximum number of ancestors to display (default: all)
407 colored (bool): If True, render the object as a colored badge when it exposes a `color` attribute
408 """
409 template_name = 'ui/attrs/nested_object.html'
411 def __init__(self, *args, linkify=None, max_depth=None, colored=False, **kwargs):
412 super().__init__(*args, **kwargs)
413 self.linkify = linkify
414 self.max_depth = max_depth
415 self.colored = colored
417 def get_context(self, obj, attr, value, context):
418 nodes = []
419 if value is not None:
420 nodes = value.get_ancestors(include_self=True)
421 if self.max_depth:
422 nodes = list(nodes)[-self.max_depth:]
423 return {
424 'nodes': nodes,
425 'linkify': self.linkify,
426 'colored': self.colored,
427 }
430class GenericForeignKeyAttr(ObjectAttribute):
431 """
432 An attribute representing a related generic relation object.
434 This attribute is similar to `RelatedObjectAttr` but uses the
435 ContentType of the related object to be displayed alongside the value.
437 Parameters:
438 linkify (bool): If True, the rendered value will be hyperlinked
439 to the related object's detail view.
440 nested (bool): If True and the related object exposes a callable
441 `get_ancestors(include_self=True)`, render the object together
442 with its ancestors as a breadcrumb, similar to `NestedObjectAttr`.
443 Non-hierarchical objects continue to render normally.
444 max_depth (int): Maximum number of ancestors to display when
445 `nested` is enabled. Ignored otherwise.
446 """
447 template_name = 'ui/attrs/generic_object.html'
449 def __init__(self, *args, linkify=None, nested=False, max_depth=None, **kwargs):
450 super().__init__(*args, **kwargs)
451 self.linkify = linkify
452 self.nested = nested
453 self.max_depth = max_depth
455 def _get_nodes(self, value):
456 """
457 Retrieves a list of nodes representing the hierarchical path to a given value.
458 """
459 if value is None:
460 return None
462 get_ancestors = getattr(value, 'get_ancestors', None)
463 if not callable(get_ancestors):
464 return None
466 nodes = list(get_ancestors(include_self=True))
468 if self.max_depth is not None:
469 nodes = nodes[-self.max_depth:]
471 return nodes
473 def get_context(self, obj, attr, value, context):
474 content_type = value._meta.verbose_name if value is not None else None
475 nodes = self._get_nodes(value) if (self.nested and value is not None) else None
477 return {
478 'content_type': content_type,
479 'linkify': self.linkify,
480 'nodes': nodes,
481 }
484class AddressAttr(MapURLMixin, ObjectAttribute):
485 """
486 A physical or mailing address.
488 Parameters:
489 map_url (bool/str): The URL to use when rendering the address. If True, the address will render as a
490 hyperlink using settings.MAPS_URL.
491 """
492 template_name = 'ui/attrs/address.html'
494 def __init__(self, *args, map_url=True, **kwargs):
495 super().__init__(*args, **kwargs)
496 self._map_url = map_url
498 def get_context(self, obj, attr, value, context):
499 map_url = self.map_url
500 # A coordinate-format MAPS_URL (containing {lat}/{lon}) cannot be used for address rendering
501 if map_url and is_coordinate_map_url(map_url):
502 map_url = None
503 return {
504 'map_url': map_url,
505 }
508class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
509 """
510 A GPS coordinates pair comprising latitude and longitude values.
512 Parameters:
513 latitude_attr (float): The name of the field containing the latitude value
514 longitude_attr (float): The name of the field containing the longitude value
515 map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
516 """
517 template_name = 'ui/attrs/gps_coordinates.html'
518 label = _('GPS coordinates')
520 def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
521 super().__init__(accessor=latitude_attr, **kwargs)
522 self.latitude_attr = latitude_attr
523 self.longitude_attr = longitude_attr
524 self._map_url = map_url
526 def render(self, obj, context):
527 latitude = resolve_attr_path(obj, self.latitude_attr)
528 longitude = resolve_attr_path(obj, self.longitude_attr)
529 if latitude is None or longitude is None:
530 return self.placeholder
531 map_url = self.map_url
532 if map_url:
533 map_url = build_coords_url(map_url, latitude, longitude)
534 return render_to_string(self.template_name, {
535 'name': context['name'],
536 'latitude': latitude,
537 'longitude': longitude,
538 'map_url': map_url,
539 })
542class DateTimeAttr(ObjectAttribute):
543 """
544 A date or datetime attribute.
546 Parameters:
547 spec (str): Controls the rendering format. Use 'date' for date-only rendering,
548 or 'seconds'/'minutes' for datetime rendering with the given precision.
549 """
550 template_name = 'ui/attrs/datetime.html'
552 def __init__(self, *args, spec='seconds', **kwargs):
553 super().__init__(*args, **kwargs)
554 self.spec = spec
556 def get_context(self, obj, attr, value, context):
557 return {
558 'spec': self.spec,
559 }
562class TimezoneAttr(ObjectAttribute):
563 """
564 A timezone value. Includes the numeric offset from UTC.
565 """
566 template_name = 'ui/attrs/timezone.html'
569class DurationAttr(TextAttr):
570 """
571 A duration (timedelta) value, rendered in a human-friendly format (e.g. 1h 5m 23s).
572 """
573 def get_value(self, obj):
574 value = resolve_attr_path(obj, self.accessor)
575 return humanize_duration(value) or None
578class TemplatedAttr(ObjectAttribute):
579 """
580 Renders an attribute using a custom template.
582 Parameters:
583 template_name (str): The name of the template to render
584 context (dict): Additional context to pass to the template when rendering
585 """
586 def __init__(self, *args, template_name, context=None, **kwargs):
587 super().__init__(*args, **kwargs)
588 self.template_name = template_name
589 self.context = context or {}
591 def get_context(self, obj, attr, value, context):
592 return {
593 **context,
594 **self.context,
595 'object': obj,
596 }
599class UtilizationAttr(ObjectAttribute):
600 """
601 Renders the value of an attribute as a utilization graph.
602 """
603 template_name = 'ui/attrs/utilization.html'
606IMPERIAL_WEIGHT = {'lb', 'oz'}
607METRIC_WEIGHT = {'kg', 'g'}
608IMPERIAL_DISTANCE = {'mi', 'ft'}
609METRIC_DISTANCE = {'km', 'm'}
610IMPERIAL_DIAMETER = {'in'}
611METRIC_DIAMETER = {'mm', 'cm'}
612IMPERIAL_FLOW_RATE = {'gpm'}
613METRIC_FLOW_RATE = {'lpm', 'm3ph'}
615# Abbreviations for the flow rate units, whose stored values are not themselves presentable
616FLOW_RATE_ABBREVIATIONS = {
617 'lpm': 'L/min',
618 'm3ph': 'm³/h',
619 'gpm': 'GPM',
620}
623def compute_weight_display(weight, weight_unit, abs_weight, system):
624 """
625 Return (display_value, display_unit) for a weight, respecting the user's measurement system.
626 abs_weight is in grams (from WeightMixin._abs_weight).
627 oz and g pass through unchanged since there is no cross-system equivalent.
628 """
629 if system == 'metric' and weight_unit in IMPERIAL_WEIGHT and abs_weight is not None:
630 return round(abs_weight / 1000, 2), 'kg'
631 if system == 'imperial' and weight_unit in METRIC_WEIGHT and abs_weight is not None:
632 lbs = round(abs_weight / 453.592, 2)
633 return lbs, 'lb' if lbs == 1 else 'lbs'
634 if weight_unit == 'lb':
635 return weight, 'lb' if weight == 1 else 'lbs'
636 return weight, weight_unit
639def compute_diameter_display(diameter, diameter_unit, abs_diameter, system):
640 """
641 Return (display_value, display_unit) for a diameter, respecting the user's measurement system.
642 abs_diameter is in millimeters (from DiameterMixin._abs_diameter).
643 """
644 if system == 'metric' and diameter_unit in IMPERIAL_DIAMETER and abs_diameter is not None:
645 return round(float(abs_diameter), 2), 'mm'
646 if system == 'imperial' and diameter_unit in METRIC_DIAMETER and abs_diameter is not None:
647 return round(float(abs_diameter) / 25.4, 2), 'in'
648 return diameter, diameter_unit
651def compute_flow_rate_display(flow_rate, flow_rate_unit, abs_flow_rate, system):
652 """
653 Return (display_value, display_unit) for a flow rate, respecting the user's measurement system.
654 abs_flow_rate is in liters per minute (from MaxFlowMixin._abs_max_flow).
655 """
656 if system == 'metric' and flow_rate_unit in IMPERIAL_FLOW_RATE and abs_flow_rate is not None:
657 return round(float(abs_flow_rate), 2), FLOW_RATE_ABBREVIATIONS['lpm']
658 if system == 'imperial' and flow_rate_unit in METRIC_FLOW_RATE and abs_flow_rate is not None:
659 return round(float(abs_flow_rate) / 3.785411784, 2), FLOW_RATE_ABBREVIATIONS['gpm']
660 return flow_rate, FLOW_RATE_ABBREVIATIONS.get(flow_rate_unit, flow_rate_unit)
663def compute_distance_display(distance, distance_unit, abs_distance, system):
664 """
665 Return (display_value, display_unit) for a distance, respecting the user's measurement system.
666 abs_distance is in metres (from DistanceMixin._abs_distance).
667 Distances < 1 km are shown in metres; < 1 mi are shown in feet.
668 """
669 if system == 'metric' and distance_unit in IMPERIAL_DISTANCE and abs_distance is not None:
670 abs_m = float(abs_distance)
671 if abs_m >= 1000:
672 return round(abs_m / 1000, 2), 'km'
673 return round(abs_m, 2), 'm'
674 if system == 'imperial' and distance_unit in METRIC_DISTANCE and abs_distance is not None:
675 abs_m = float(abs_distance)
676 if abs_m >= 1609.344:
677 return round(abs_m / 1609.344, 2), 'mi'
678 return round(abs_m / 0.3048, 2), 'ft'
679 return distance, distance_unit
682class WeightAttr(ObjectAttribute):
683 """
684 A weight attribute that converts to the user's preferred measurement system.
686 Parameters:
687 unit_attr (str): Name of the field holding the weight unit (default: 'weight_unit')
688 abs_attr (str): The internal _abs_weight field name on WeightMixin (stored in grams).
689 Accessed via Python — not subject to Django's template underscore restriction.
690 """
691 template_name = 'ui/attrs/numeric.html'
693 def __init__(self, *args, unit_attr='weight_unit', abs_attr='_abs_weight', **kwargs):
694 super().__init__(*args, **kwargs)
695 self.unit_attr = unit_attr
696 self.abs_attr = abs_attr
698 def render(self, obj, context):
699 weight = resolve_attr_path(obj, self.accessor)
700 if weight is None:
701 return self.placeholder
703 system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
704 unit = resolve_attr_path(obj, self.unit_attr)
705 abs_weight = resolve_attr_path(obj, self.abs_attr)
706 display_value, display_unit = compute_weight_display(weight, unit, abs_weight, system)
708 return render_to_string(self.template_name, {
709 'name': context['name'],
710 'value': display_value,
711 'unit': display_unit,
712 })
715class DistanceAttr(ObjectAttribute):
716 """
717 A distance attribute that converts to the user's preferred measurement system.
719 Parameters:
720 unit_attr (str): Name of the field holding the distance unit (default: 'distance_unit')
721 abs_attr (str): The internal _abs_distance field name on DistanceMixin (stored in metres).
722 Accessed via Python — not subject to Django's template underscore restriction.
723 """
724 template_name = 'ui/attrs/numeric.html'
726 def __init__(self, *args, unit_attr='distance_unit', abs_attr='_abs_distance', **kwargs):
727 super().__init__(*args, **kwargs)
728 self.unit_attr = unit_attr
729 self.abs_attr = abs_attr
731 def render(self, obj, context):
732 distance = resolve_attr_path(obj, self.accessor)
733 if distance is None:
734 return self.placeholder
736 system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
737 unit = resolve_attr_path(obj, self.unit_attr)
738 abs_distance = resolve_attr_path(obj, self.abs_attr)
739 display_value, display_unit = compute_distance_display(distance, unit, abs_distance, system)
741 return render_to_string(self.template_name, {
742 'name': context['name'],
743 'value': display_value,
744 'unit': display_unit,
745 })
748class DiameterAttr(ObjectAttribute):
749 """
750 A diameter attribute that converts to the user's preferred measurement system.
752 Parameters:
753 unit_attr (str): Name of the field holding the diameter unit (default: 'diameter_unit')
754 abs_attr (str): The internal _abs_diameter field name on DiameterMixin (stored in millimeters).
755 Accessed via Python — not subject to Django's template underscore restriction.
756 """
757 template_name = 'ui/attrs/measurement.html'
759 def __init__(self, *args, unit_attr='diameter_unit', abs_attr='_abs_diameter', **kwargs):
760 super().__init__(*args, **kwargs)
761 self.unit_attr = unit_attr
762 self.abs_attr = abs_attr
764 def render(self, obj, context):
765 diameter = resolve_attr_path(obj, self.accessor)
766 if diameter is None:
767 return self.placeholder
769 system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
770 unit = resolve_attr_path(obj, self.unit_attr)
771 abs_diameter = resolve_attr_path(obj, self.abs_attr)
772 display_value, display_unit = compute_diameter_display(diameter, unit, abs_diameter, system)
774 return render_to_string(self.template_name, {
775 'name': context['name'],
776 'value': display_value,
777 'unit': display_unit,
778 })
781class FlowRateAttr(ObjectAttribute):
782 """
783 A flow rate attribute that converts to the user's preferred measurement system.
785 Parameters:
786 unit_attr (str): Name of the field holding the flow rate unit (default: 'max_flow_unit')
787 abs_attr (str): The internal _abs_max_flow field name on MaxFlowMixin (stored in liters per
788 minute). Accessed via Python — not subject to Django's template underscore restriction.
789 """
790 template_name = 'ui/attrs/measurement.html'
792 def __init__(self, *args, unit_attr='max_flow_unit', abs_attr='_abs_max_flow', **kwargs):
793 super().__init__(*args, **kwargs)
794 self.unit_attr = unit_attr
795 self.abs_attr = abs_attr
797 def render(self, obj, context):
798 flow_rate = resolve_attr_path(obj, self.accessor)
799 if flow_rate is None:
800 return self.placeholder
802 system = (context.get('preferences') or {}).get('ui.measurement_system') or ''
803 unit = resolve_attr_path(obj, self.unit_attr)
804 abs_flow_rate = resolve_attr_path(obj, self.abs_attr)
805 display_value, display_unit = compute_flow_rate_display(flow_rate, unit, abs_flow_rate, system)
807 return render_to_string(self.template_name, {
808 'name': context['name'],
809 'value': display_value,
810 'unit': display_unit,
811 })