Coverage for utilities/templatetags/helpers.py: 19%

258 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import json 

2from typing import Any 

3from urllib.parse import quote 

4 

5from django import template 

6from django.urls import NoReverseMatch, reverse 

7from django.utils.html import conditional_escape 

8from django.utils.translation import gettext_lazy as _ 

9 

10from core.models import ObjectType 

11from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT 

12from netbox.ui.attrs import ( 

13 compute_diameter_display, 

14 compute_distance_display, 

15 compute_flow_rate_display, 

16 compute_weight_display, 

17) 

18from utilities.forms import TableConfigForm, get_selected_values 

19from utilities.forms.mixins import FORM_FIELD_LOOKUPS 

20from utilities.views import get_action_url, get_viewname 

21 

22__all__ = ( 

23 'action_url', 

24 'applied_filters', 

25 'as_range', 

26 'display_diameter', 

27 'display_distance', 

28 'display_flow_rate', 

29 'display_weight', 

30 'divide', 

31 'get_item', 

32 'get_key', 

33 'humanize_disk_capacity', 

34 'humanize_ram_capacity', 

35 'humanize_speed', 

36 'icon_from_status', 

37 'kg_to_pounds', 

38 'meters_to_feet', 

39 'percentage', 

40 'startswith', 

41 'status_from_tag', 

42 'table_config_form', 

43 'utilization_graph', 

44 'validated_viewname', 

45 'viewname', 

46) 

47 

48register = template.Library() 

49 

50 

51# 

52# Filters 

53# 

54 

55 

56@register.filter() 

57def viewname(model, action): 

58 """ 

59 Return the view name for the given model and action. Does not perform any validation. 

60 """ 

61 return get_viewname(model, action) 

62 

63 

64@register.filter() 

65def validated_viewname(model, action): 

66 """ 

67 Return the view name for the given model and action if valid, or None if invalid. 

68 """ 

69 viewname = get_viewname(model, action) 

70 

71 # Validate the view name 

72 try: 

73 reverse(viewname) 

74 return viewname 

75 except NoReverseMatch: 

76 return None 

77 

78 

79class ActionURLNode(template.Node): 

80 """Template node for the {% action_url %} template tag.""" 

81 

82 child_nodelists = () 

83 

84 def __init__(self, model, action, kwargs, asvar=None): 

85 self.model = model 

86 self.action = action 

87 self.kwargs = kwargs 

88 self.asvar = asvar 

89 

90 def __repr__(self): 

91 return ( 

92 f"<{self.__class__.__qualname__} " 

93 f"model='{self.model}' " 

94 f"action='{self.action}' " 

95 f"kwargs={repr(self.kwargs)} " 

96 f"as={repr(self.asvar)}>" 

97 ) 

98 

99 def render(self, context): 

100 """ 

101 Render the action URL node. 

102 

103 Args: 

104 context: The template context 

105 

106 Returns: 

107 The resolved URL or empty string if using 'as' syntax 

108 

109 Raises: 

110 NoReverseMatch: If the URL cannot be resolved and not using 'as' syntax 

111 """ 

112 # Resolve model and kwargs from context 

113 model = self.model.resolve(context) 

114 kwargs = {k: v.resolve(context) for k, v in self.kwargs.items()} 

115 

116 # Get the action URL using the utility function 

117 try: 

118 url = get_action_url(model, action=self.action, kwargs=kwargs) 

119 except NoReverseMatch: 

120 if self.asvar is None: 

121 raise 

122 url = "" 

123 

124 # Handle variable assignment or return escaped URL 

125 if self.asvar: 

126 context[self.asvar] = url 

127 return "" 

128 

129 return conditional_escape(url) if context.autoescape else url 

130 

131 

132@register.tag 

133def action_url(parser, token): 

134 """ 

135 Return an absolute URL matching the given model and action. 

136 

137 This is a way to define links that aren't tied to a particular URL 

138 configuration:: 

139 

140 {% action_url model "action_name" %} 

141 

142 or 

143 

144 {% action_url model "action_name" pk=object.pk %} 

145 

146 or 

147 

148 {% action_url model "action_name" pk=object.pk as variable_name %} 

149 

150 The first argument is a model or instance. The second argument is the action name. 

151 Additional keyword arguments can be passed for URL parameters. 

152 

153 For example, if you have a Device model and want to link to its edit action:: 

154 

155 {% action_url device "edit" %} 

156 

157 This will generate a URL like ``/dcim/devices/123/edit/``. 

158 

159 You can also pass additional parameters:: 

160 

161 {% action_url device "journal" pk=device.pk %} 

162 

163 Or assign the URL to a variable:: 

164 

165 {% action_url device "edit" as edit_url %} 

166 """ 

167 # Parse the token contents 

168 bits = token.split_contents() 

169 if len(bits) < 3: 

170 raise template.TemplateSyntaxError( 

171 f"'{bits[0]}' takes at least two arguments, a model and an action." 

172 ) 

173 

174 # Extract model and action 

175 model = parser.compile_filter(bits[1]) 

176 action = bits[2].strip('"\'') # Remove quotes from literal string 

177 kwargs = {} 

178 asvar = None 

179 bits = bits[3:] 

180 

181 # Handle 'as' syntax for variable assignment 

182 if len(bits) >= 2 and bits[-2] == "as": 

183 asvar = bits[-1] 

184 bits = bits[:-2] 

185 

186 # Parse remaining arguments as kwargs 

187 for bit in bits: 

188 if '=' not in bit: 

189 raise template.TemplateSyntaxError( 

190 f"'{token.contents.split()[0]}' keyword arguments must be in the format 'name=value'" 

191 ) 

192 name, value = bit.split('=', 1) 

193 kwargs[name] = parser.compile_filter(value) 

194 

195 return ActionURLNode(model, action, kwargs, asvar) 

196 

197 

198def _format_speed(speed, divisor, unit): 

199 """ 

200 Format a speed value with a given divisor and unit. 

201 

202 Handles decimal values and strips trailing zeros for clean output. 

203 """ 

204 whole, remainder = divmod(speed, divisor) 

205 if remainder == 0: 

206 return f'{whole} {unit}' 

207 

208 # Divisors are powers of 10, so len(str(divisor)) - 1 matches the decimal precision. 

209 precision = len(str(divisor)) - 1 

210 fraction = f'{remainder:0{precision}d}'.rstrip('0') 

211 return f'{whole}.{fraction} {unit}' 

212 

213 

214@register.filter() 

215def humanize_speed(speed): 

216 """ 

217 Humanize speeds given in Kbps, always using the largest appropriate unit. 

218 

219 Decimal values are displayed when the result is not a whole number; 

220 trailing zeros after the decimal point are stripped for clean output. 

221 

222 Examples: 

223 

224 1_544 => "1.544 Mbps" 

225 100_000 => "100 Mbps" 

226 1_000_000 => "1 Gbps" 

227 2_500_000 => "2.5 Gbps" 

228 10_000_000 => "10 Gbps" 

229 800_000_000 => "800 Gbps" 

230 1_600_000_000 => "1.6 Tbps" 

231 """ 

232 if not speed: 

233 return '' 

234 

235 speed = int(speed) 

236 

237 if speed >= 1_000_000_000: 

238 return _format_speed(speed, 1_000_000_000, 'Tbps') 

239 if speed >= 1_000_000: 

240 return _format_speed(speed, 1_000_000, 'Gbps') 

241 if speed >= 1_000: 

242 return _format_speed(speed, 1_000, 'Mbps') 

243 return f'{speed} Kbps' 

244 

245 

246def _humanize_capacity(value, divisor=1000): 

247 """ 

248 Express a capacity value in the most suitable unit (e.g. GB, TiB, etc.). 

249 

250 The value is treated as a unitless base-unit quantity; the divisor determines 

251 both the scaling thresholds and the label convention: 

252 - 1000: SI labels (MB, GB, TB, PB) 

253 - 1024: IEC labels (MiB, GiB, TiB, PiB) 

254 """ 

255 if not value: 

256 return "" 

257 

258 if divisor == 1024: 

259 labels = ('MiB', 'GiB', 'TiB', 'PiB') 

260 else: 

261 labels = ('MB', 'GB', 'TB', 'PB') 

262 

263 PB_SIZE = divisor**3 

264 TB_SIZE = divisor**2 

265 GB_SIZE = divisor 

266 

267 if value >= PB_SIZE: 

268 return f"{value / PB_SIZE:.2f} {labels[3]}" 

269 if value >= TB_SIZE: 

270 return f"{value / TB_SIZE:.2f} {labels[2]}" 

271 if value >= GB_SIZE: 

272 return f"{value / GB_SIZE:.2f} {labels[1]}" 

273 return f"{value} {labels[0]}" 

274 

275 

276@register.filter() 

277def humanize_disk_capacity(value): 

278 """ 

279 Express a disk capacity in the most suitable unit, using the DISK_BASE_UNIT 

280 setting to select SI (MB/GB) or IEC (MiB/GiB) labels. 

281 """ 

282 return _humanize_capacity(value, DISK_BASE_UNIT) 

283 

284 

285@register.filter() 

286def humanize_ram_capacity(value): 

287 """ 

288 Express a RAM capacity in the most suitable unit, using the RAM_BASE_UNIT 

289 setting to select SI (MB/GB) or IEC (MiB/GiB) labels. 

290 """ 

291 return _humanize_capacity(value, RAM_BASE_UNIT) 

292 

293 

294@register.filter() 

295def divide(x, y): 

296 """ 

297 Return x/y (rounded). 

298 """ 

299 if x is None or y is None: 

300 return None 

301 return round(x / y) 

302 

303 

304@register.filter() 

305def percentage(x, y): 

306 """ 

307 Return x/y as a percentage. 

308 """ 

309 if x is None or y is None: 

310 return None 

311 

312 return round(x / y * 100, 1) 

313 

314 

315@register.filter() 

316def as_range(n): 

317 """ 

318 Return a range of n items. 

319 """ 

320 try: 

321 int(n) 

322 except TypeError: 

323 return list() 

324 return range(n) 

325 

326 

327@register.filter() 

328def meters_to_feet(n): 

329 """ 

330 Convert a length from meters to feet. 

331 """ 

332 return float(n) * 3.28084 

333 

334 

335@register.filter() 

336def kg_to_pounds(n): 

337 """ 

338 Convert a weight from kilograms to pounds. 

339 """ 

340 return float(n) * 2.204623 

341 

342 

343@register.simple_tag(takes_context=True) 

344def display_weight(context, weight, weight_unit, abs_weight): 

345 """ 

346 Render a weight value respecting the user's ui.measurement_system preference. 

347 """ 

348 if weight is None: 

349 return '' 

350 system = (context.get('preferences') or {}).get('ui.measurement_system') or '' 

351 value, unit = compute_weight_display(weight, weight_unit, abs_weight, system) 

352 return f'{value:g} {unit}' 

353 

354 

355@register.simple_tag(takes_context=True) 

356def display_distance(context, distance, distance_unit, abs_distance): 

357 """ 

358 Render a distance value respecting the user's ui.measurement_system preference. 

359 """ 

360 if distance is None: 

361 return '' 

362 system = (context.get('preferences') or {}).get('ui.measurement_system') or '' 

363 value, unit = compute_distance_display(distance, distance_unit, abs_distance, system) 

364 return f'{value:g} {unit}' 

365 

366 

367@register.simple_tag(takes_context=True) 

368def display_diameter(context, diameter, diameter_unit, abs_diameter): 

369 """ 

370 Render a diameter value respecting the user's ui.measurement_system preference. 

371 """ 

372 if diameter is None: 

373 return '' 

374 system = (context.get('preferences') or {}).get('ui.measurement_system') or '' 

375 value, unit = compute_diameter_display(diameter, diameter_unit, abs_diameter, system) 

376 return f'{value:g} {unit}' 

377 

378 

379@register.simple_tag(takes_context=True) 

380def display_flow_rate(context, flow_rate, flow_rate_unit, abs_flow_rate): 

381 """ 

382 Render a flow rate value respecting the user's ui.measurement_system preference. 

383 """ 

384 if flow_rate is None: 

385 return '' 

386 system = (context.get('preferences') or {}).get('ui.measurement_system') or '' 

387 value, unit = compute_flow_rate_display(flow_rate, flow_rate_unit, abs_flow_rate, system) 

388 return f'{value:g} {unit}' 

389 

390 

391@register.filter("startswith") 

392def startswith(text: str, starts: str) -> bool: 

393 """ 

394 Template implementation of `str.startswith()`. 

395 """ 

396 if isinstance(text, str): 

397 return text.startswith(starts) 

398 return False 

399 

400 

401@register.filter 

402def get_key(value: dict, arg: str) -> Any: 

403 """ 

404 Template implementation of `dict.get()`, for accessing dict values 

405 by key when the key is not able to be used in a template. For 

406 example, `{"ui.colormode": "dark"}`. 

407 """ 

408 return value.get(arg, None) 

409 

410 

411@register.filter 

412def get_item(value: object, attr: str) -> Any: 

413 """ 

414 Template implementation of `__getitem__`, for accessing the `__getitem__` method 

415 of a class from a template. 

416 """ 

417 return value[attr] 

418 

419 

420@register.filter 

421def status_from_tag(tag: str = "info") -> str: 

422 """ 

423 Determine Bootstrap theme status/level from Django's Message.level_tag. 

424 """ 

425 status_map = { 

426 'warning': 'warning', 

427 'success': 'success', 

428 'error': 'danger', 

429 'danger': 'danger', 

430 'debug': 'info', 

431 'info': 'info', 

432 } 

433 return status_map.get(tag.lower(), 'info') 

434 

435 

436@register.filter 

437def icon_from_status(status: str = "info") -> str: 

438 """ 

439 Determine icon class name from Bootstrap theme status/level. 

440 """ 

441 icon_map = { 

442 'warning': 'alert', 

443 'success': 'check-circle', 

444 'danger': 'alert', 

445 'info': 'information', 

446 } 

447 return icon_map.get(status.lower(), 'information') 

448 

449 

450# 

451# Tags 

452# 

453 

454@register.inclusion_tag('helpers/utilization_graph.html') 

455def utilization_graph(utilization, warning_threshold=75, danger_threshold=90): 

456 """ 

457 Display a horizontal bar graph indicating a percentage of utilization. 

458 """ 

459 if utilization == 100: 

460 bar_class = 'bg-secondary' 

461 elif danger_threshold and utilization >= danger_threshold: 

462 bar_class = 'bg-danger' 

463 elif warning_threshold and utilization >= warning_threshold: 

464 bar_class = 'bg-warning' 

465 elif warning_threshold or danger_threshold: 

466 bar_class = 'bg-success' 

467 else: 

468 bar_class = 'bg-gray' 

469 return { 

470 'utilization': utilization, 

471 'bar_class': bar_class, 

472 } 

473 

474 

475@register.inclusion_tag('helpers/table_config_form.html') 

476def table_config_form(table, table_name=None): 

477 return { 

478 'table_name': table_name or table.__class__.__name__, 

479 'form': TableConfigForm(table=table), 

480 } 

481 

482 

483@register.inclusion_tag('helpers/applied_filters.html', takes_context=True) 

484def applied_filters(context, model, form, query_params): 

485 """ 

486 Display the active filters for a given filter form. 

487 """ 

488 user = context['request'].user 

489 form.is_valid() # Ensure cleaned_data has been set 

490 

491 applied_filters = [] 

492 for filter_name in form.changed_data: 

493 if filter_name not in form.cleaned_data: 

494 continue 

495 

496 querydict = query_params.copy() 

497 

498 # Check if this is a modifier-enhanced field 

499 # Field may be in querydict as field__lookup instead of field 

500 param_name = None 

501 if filter_name in querydict: 

502 param_name = filter_name 

503 else: 

504 # Check for modifier variants (field__ic, field__isw, etc.) 

505 for key in querydict.keys(): 

506 if key.startswith(f'{filter_name}__'): 

507 param_name = key 

508 break 

509 

510 if param_name is None: 

511 continue 

512 

513 # Skip saved filters, as they're displayed alongside the quick search widget 

514 if filter_name == 'filter_id': 

515 continue 

516 

517 bound_field = form.fields[filter_name].get_bound_field(form, filter_name) 

518 querydict.pop(param_name) 

519 

520 # Extract modifier from parameter name (e.g., "serial__ic" → "ic") 

521 if '__' in param_name: 

522 modifier = param_name.split('__', 1)[1] 

523 else: 

524 modifier = 'exact' 

525 

526 # Get display value 

527 display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)]) 

528 

529 # Get the correct lookup label for this field's type 

530 lookup_label = None 

531 if modifier != 'exact': 

532 field = form.fields[filter_name] 

533 for field_class in field.__class__.__mro__: 

534 if field_lookups := FORM_FIELD_LOOKUPS.get(field_class): 

535 for lookup_code, label in field_lookups: 

536 if lookup_code == modifier: 

537 lookup_label = label 

538 break 

539 if lookup_label: 

540 break 

541 

542 # Special handling for empty lookup (boolean value) 

543 if modifier == 'empty': 

544 if display_value.lower() in ('true', '1'): 

545 link_text = f'{bound_field.label} {_("is empty")}' 

546 else: 

547 link_text = f'{bound_field.label} {_("is not empty")}' 

548 elif lookup_label: 

549 link_text = f'{bound_field.label} {lookup_label}: {display_value}' 

550 else: 

551 link_text = f'{bound_field.label}: {display_value}' 

552 

553 applied_filters.append({ 

554 'name': param_name, # Use actual param name for removal link 

555 'value': form.cleaned_data.get(filter_name), 

556 'link_url': f'?{querydict.urlencode()}', 

557 'link_text': link_text, 

558 }) 

559 

560 # Handle empty modifier pills separately. `FilterModifierWidget.value_from_datadict()` 

561 # returns None for fields with a `field__empty` query parameter so that the underlying 

562 # form field does not attempt to validate 'true'/'false' as a real field value (which 

563 # would raise a ValidationError for ModelChoiceField). Because the value is None, these 

564 # fields never appear in `form.changed_data`, so we build their pills directly from the 

565 # query parameters here. 

566 for param_name, param_value in query_params.items(): 

567 if not param_name.endswith('__empty'): 

568 continue 

569 field_name = param_name[:-len('__empty')] 

570 if field_name not in form.fields or field_name == 'filter_id': 

571 continue 

572 

573 querydict = query_params.copy() 

574 querydict.pop(param_name) 

575 label = form.fields[field_name].label or field_name 

576 

577 if param_value.lower() in ('true', '1'): 

578 link_text = f'{label} {_("is empty")}' 

579 else: 

580 link_text = f'{label} {_("is not empty")}' 

581 

582 applied_filters.append({ 

583 'name': param_name, 

584 'value': param_value, 

585 'link_url': f'?{querydict.urlencode()}', 

586 'link_text': link_text, 

587 }) 

588 

589 save_link = None 

590 if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET: 

591 object_type = ObjectType.objects.get_for_model(model).pk 

592 parameters = json.dumps(dict(context['request'].GET.lists())) 

593 url = reverse('extras:savedfilter_add') 

594 save_link = f"{url}?object_types={object_type}&parameters={quote(parameters)}" 

595 

596 return { 

597 'applied_filters': applied_filters, 

598 'save_link': save_link, 

599 }