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
« 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
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 _
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
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)
48register = template.Library()
51#
52# Filters
53#
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)
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)
71 # Validate the view name
72 try:
73 reverse(viewname)
74 return viewname
75 except NoReverseMatch:
76 return None
79class ActionURLNode(template.Node):
80 """Template node for the {% action_url %} template tag."""
82 child_nodelists = ()
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
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 )
99 def render(self, context):
100 """
101 Render the action URL node.
103 Args:
104 context: The template context
106 Returns:
107 The resolved URL or empty string if using 'as' syntax
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()}
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 = ""
124 # Handle variable assignment or return escaped URL
125 if self.asvar:
126 context[self.asvar] = url
127 return ""
129 return conditional_escape(url) if context.autoescape else url
132@register.tag
133def action_url(parser, token):
134 """
135 Return an absolute URL matching the given model and action.
137 This is a way to define links that aren't tied to a particular URL
138 configuration::
140 {% action_url model "action_name" %}
142 or
144 {% action_url model "action_name" pk=object.pk %}
146 or
148 {% action_url model "action_name" pk=object.pk as variable_name %}
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.
153 For example, if you have a Device model and want to link to its edit action::
155 {% action_url device "edit" %}
157 This will generate a URL like ``/dcim/devices/123/edit/``.
159 You can also pass additional parameters::
161 {% action_url device "journal" pk=device.pk %}
163 Or assign the URL to a variable::
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 )
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:]
181 # Handle 'as' syntax for variable assignment
182 if len(bits) >= 2 and bits[-2] == "as":
183 asvar = bits[-1]
184 bits = bits[:-2]
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)
195 return ActionURLNode(model, action, kwargs, asvar)
198def _format_speed(speed, divisor, unit):
199 """
200 Format a speed value with a given divisor and unit.
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}'
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}'
214@register.filter()
215def humanize_speed(speed):
216 """
217 Humanize speeds given in Kbps, always using the largest appropriate unit.
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.
222 Examples:
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 ''
235 speed = int(speed)
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'
246def _humanize_capacity(value, divisor=1000):
247 """
248 Express a capacity value in the most suitable unit (e.g. GB, TiB, etc.).
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 ""
258 if divisor == 1024:
259 labels = ('MiB', 'GiB', 'TiB', 'PiB')
260 else:
261 labels = ('MB', 'GB', 'TB', 'PB')
263 PB_SIZE = divisor**3
264 TB_SIZE = divisor**2
265 GB_SIZE = divisor
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]}"
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)
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)
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)
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
312 return round(x / y * 100, 1)
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)
327@register.filter()
328def meters_to_feet(n):
329 """
330 Convert a length from meters to feet.
331 """
332 return float(n) * 3.28084
335@register.filter()
336def kg_to_pounds(n):
337 """
338 Convert a weight from kilograms to pounds.
339 """
340 return float(n) * 2.204623
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}'
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}'
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}'
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}'
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
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)
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]
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')
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')
450#
451# Tags
452#
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 }
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 }
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
491 applied_filters = []
492 for filter_name in form.changed_data:
493 if filter_name not in form.cleaned_data:
494 continue
496 querydict = query_params.copy()
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
510 if param_name is None:
511 continue
513 # Skip saved filters, as they're displayed alongside the quick search widget
514 if filter_name == 'filter_id':
515 continue
517 bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
518 querydict.pop(param_name)
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'
526 # Get display value
527 display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])
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
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}'
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 })
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
573 querydict = query_params.copy()
574 querydict.pop(param_name)
575 label = form.fields[field_name].label or field_name
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")}'
582 applied_filters.append({
583 'name': param_name,
584 'value': param_value,
585 'link_url': f'?{querydict.urlencode()}',
586 'link_text': link_text,
587 })
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}¶meters={quote(parameters)}"
596 return {
597 'applied_filters': applied_filters,
598 'save_link': save_link,
599 }