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

1from django.template.loader import render_to_string 

2from django.utils.safestring import mark_safe 

3from django.utils.translation import gettext_lazy as _ 

4 

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 

9 

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) 

35 

36PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>' 

37 

38IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync') 

39 

40 

41# 

42# Mixins 

43# 

44 

45class MapURLMixin: 

46 _map_url = None 

47 

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 

55 

56 

57# 

58# Attributes 

59# 

60 

61class ObjectAttribute: 

62 """ 

63 Base class for representing an attribute of an object. 

64 

65 Attributes: 

66 template_name (str): The name of the template to render 

67 placeholder (str): HTML to render for empty/null values 

68 

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) 

76 

77 def __init__(self, accessor, label=None): 

78 self.accessor = accessor 

79 if label is not None: 

80 self.label = label 

81 

82 def get_value(self, obj): 

83 """ 

84 Return the value of the attribute. 

85 

86 Parameters: 

87 obj (object): The object for which the attribute is being rendered 

88 """ 

89 return resolve_attr_path(obj, self.accessor) 

90 

91 def get_context(self, obj, attr, value, context): 

92 """ 

93 Return any additional template context used to render the attribute value. 

94 

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 {} 

102 

103 def render(self, obj, context): 

104 name = context['name'] 

105 value = self.get_value(obj) 

106 

107 # If the value is empty, render a placeholder 

108 if value in (None, ''): 

109 return self.placeholder 

110 

111 return render_to_string(self.template_name, { 

112 **self.get_context(obj, name, value, context), 

113 'name': name, 

114 'value': value, 

115 }) 

116 

117 

118class TextAttr(ObjectAttribute): 

119 """ 

120 A text attribute. 

121 

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' 

128 

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 

134 

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 

141 

142 def get_context(self, obj, attr, value, context): 

143 return { 

144 'style': self.style, 

145 'copy_button': self.copy_button, 

146 } 

147 

148 

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

155 

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) 

163 

164 

165class NumericAttr(ObjectAttribute): 

166 """ 

167 An integer or float attribute. 

168 

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' 

174 

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 

179 

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 } 

186 

187 

188class ChoiceAttr(ObjectAttribute): 

189 """ 

190 A selection from a set of choices. 

191 

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' 

199 

200 def _resolve_target(self, obj): 

201 if not self.accessor or '.' not in self.accessor: 

202 return obj, self.accessor 

203 

204 object_accessor, field_name = self.accessor.rsplit('.', 1) 

205 return resolve_attr_path(obj, object_accessor), field_name 

206 

207 def get_value(self, obj): 

208 target, field_name = self._resolve_target(obj) 

209 if target is None: 

210 return None 

211 

212 display = getattr(target, f'get_{field_name}_display', None) 

213 if callable(display): 

214 return display() 

215 

216 return resolve_attr_path(target, field_name) 

217 

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} 

222 

223 get_color = getattr(target, f'get_{field_name}_color', None) 

224 bg_color = get_color() if callable(get_color) else None 

225 

226 return { 

227 'bg_color': bg_color, 

228 } 

229 

230 

231class BooleanAttr(ObjectAttribute): 

232 """ 

233 A boolean attribute. 

234 

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' 

239 

240 def __init__(self, *args, display_false=True, **kwargs): 

241 super().__init__(*args, **kwargs) 

242 self.display_false = display_false 

243 

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 

249 

250 

251class ColorAttr(ObjectAttribute): 

252 """ 

253 An RGB color value. 

254 """ 

255 template_name = 'ui/attrs/color.html' 

256 label = _('Color') 

257 

258 

259class ImageAttr(ObjectAttribute): 

260 """ 

261 An attribute representing an image field on the model. Displays the uploaded image. 

262 

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' 

268 

269 def __init__(self, *args, load_lazy=True, decoding=None, **kwargs): 

270 super().__init__(*args, **kwargs) 

271 self.load_lazy = load_lazy 

272 

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 ) 

279 

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 

286 

287 def get_context(self, obj, attr, value, context): 

288 return { 

289 'decoding': self.decoding, 

290 'load_lazy': self.load_lazy, 

291 } 

292 

293 

294class RelatedObjectAttr(ObjectAttribute): 

295 """ 

296 An attribute representing a related object. 

297 

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' 

305 

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 

311 

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 } 

319 

320 

321class RelatedObjectListAttr(RelatedObjectAttr): 

322 """ 

323 An attribute representing a list of related objects. 

324 

325 The accessor may resolve to a related manager or queryset. 

326 

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

332 

333 template_name = 'ui/attrs/object_list.html' 

334 

335 def __init__(self, *args, max_items=None, overflow_indicator='…', **kwargs): 

336 super().__init__(*args, **kwargs) 

337 

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 ) 

344 

345 self.max_items = max_items 

346 self.overflow_indicator = overflow_indicator 

347 

348 def _get_items(self, items): 

349 """ 

350 Retrieve items from the given object using the accessor path. 

351 

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 

357 

358 if hasattr(items, 'all'): 

359 items = items.all() 

360 

361 if self.max_items is None: 

362 return list(items), False 

363 

364 items = list(items[:self.max_items + 1]) 

365 has_more = len(items) > self.max_items 

366 

367 return items[:self.max_items], has_more 

368 

369 def get_context(self, obj, attr, value, context): 

370 items, has_more = self._get_items(value) 

371 

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 } 

384 

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) 

389 

390 if not context_data['items']: 

391 return self.placeholder 

392 

393 return render_to_string(self.template_name, { 

394 'name': name, 

395 **context_data, 

396 }) 

397 

398 

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. 

403 

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' 

410 

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 

416 

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 } 

428 

429 

430class GenericForeignKeyAttr(ObjectAttribute): 

431 """ 

432 An attribute representing a related generic relation object. 

433 

434 This attribute is similar to `RelatedObjectAttr` but uses the 

435 ContentType of the related object to be displayed alongside the value. 

436 

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' 

448 

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 

454 

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 

461 

462 get_ancestors = getattr(value, 'get_ancestors', None) 

463 if not callable(get_ancestors): 

464 return None 

465 

466 nodes = list(get_ancestors(include_self=True)) 

467 

468 if self.max_depth is not None: 

469 nodes = nodes[-self.max_depth:] 

470 

471 return nodes 

472 

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 

476 

477 return { 

478 'content_type': content_type, 

479 'linkify': self.linkify, 

480 'nodes': nodes, 

481 } 

482 

483 

484class AddressAttr(MapURLMixin, ObjectAttribute): 

485 """ 

486 A physical or mailing address. 

487 

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' 

493 

494 def __init__(self, *args, map_url=True, **kwargs): 

495 super().__init__(*args, **kwargs) 

496 self._map_url = map_url 

497 

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 } 

506 

507 

508class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute): 

509 """ 

510 A GPS coordinates pair comprising latitude and longitude values. 

511 

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

519 

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 

525 

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

540 

541 

542class DateTimeAttr(ObjectAttribute): 

543 """ 

544 A date or datetime attribute. 

545 

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' 

551 

552 def __init__(self, *args, spec='seconds', **kwargs): 

553 super().__init__(*args, **kwargs) 

554 self.spec = spec 

555 

556 def get_context(self, obj, attr, value, context): 

557 return { 

558 'spec': self.spec, 

559 } 

560 

561 

562class TimezoneAttr(ObjectAttribute): 

563 """ 

564 A timezone value. Includes the numeric offset from UTC. 

565 """ 

566 template_name = 'ui/attrs/timezone.html' 

567 

568 

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 

576 

577 

578class TemplatedAttr(ObjectAttribute): 

579 """ 

580 Renders an attribute using a custom template. 

581 

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 {} 

590 

591 def get_context(self, obj, attr, value, context): 

592 return { 

593 **context, 

594 **self.context, 

595 'object': obj, 

596 } 

597 

598 

599class UtilizationAttr(ObjectAttribute): 

600 """ 

601 Renders the value of an attribute as a utilization graph. 

602 """ 

603 template_name = 'ui/attrs/utilization.html' 

604 

605 

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'} 

614 

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} 

621 

622 

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 

637 

638 

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 

649 

650 

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) 

661 

662 

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 

680 

681 

682class WeightAttr(ObjectAttribute): 

683 """ 

684 A weight attribute that converts to the user's preferred measurement system. 

685 

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' 

692 

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 

697 

698 def render(self, obj, context): 

699 weight = resolve_attr_path(obj, self.accessor) 

700 if weight is None: 

701 return self.placeholder 

702 

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) 

707 

708 return render_to_string(self.template_name, { 

709 'name': context['name'], 

710 'value': display_value, 

711 'unit': display_unit, 

712 }) 

713 

714 

715class DistanceAttr(ObjectAttribute): 

716 """ 

717 A distance attribute that converts to the user's preferred measurement system. 

718 

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' 

725 

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 

730 

731 def render(self, obj, context): 

732 distance = resolve_attr_path(obj, self.accessor) 

733 if distance is None: 

734 return self.placeholder 

735 

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) 

740 

741 return render_to_string(self.template_name, { 

742 'name': context['name'], 

743 'value': display_value, 

744 'unit': display_unit, 

745 }) 

746 

747 

748class DiameterAttr(ObjectAttribute): 

749 """ 

750 A diameter attribute that converts to the user's preferred measurement system. 

751 

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' 

758 

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 

763 

764 def render(self, obj, context): 

765 diameter = resolve_attr_path(obj, self.accessor) 

766 if diameter is None: 

767 return self.placeholder 

768 

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) 

773 

774 return render_to_string(self.template_name, { 

775 'name': context['name'], 

776 'value': display_value, 

777 'unit': display_unit, 

778 }) 

779 

780 

781class FlowRateAttr(ObjectAttribute): 

782 """ 

783 A flow rate attribute that converts to the user's preferred measurement system. 

784 

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' 

791 

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 

796 

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 

801 

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) 

806 

807 return render_to_string(self.template_name, { 

808 'name': context['name'], 

809 'value': display_value, 

810 'unit': display_unit, 

811 })