Jelajahi Sumber

Closes #8600: Document built-in template tags & filters

jeremystretch 4 tahun lalu
induk
melakukan
7c105019d8

+ 40 - 0
docs/plugins/development/templates.md

@@ -193,3 +193,43 @@ This template is used by the `BulkDeleteView` generic view to delete multiple ob
 | `form`       | Yes      | The bulk delete form class                                      |
 | `table`      | Yes      | The table class used for rendering the list of objects          |
 | `return_url` | Yes      | The URL to which the user is redirect after submitting the form |
+
+## Tags
+
+The following custom template tags are available in NetBox.
+
+!!! info
+    These are loaded automatically by the template backend: You do _not_ need to include a `{% load %}` tag in your template to activate them.
+
+::: utilities.templatetags.builtins.tags.badge
+
+::: utilities.templatetags.builtins.tags.checkmark
+
+::: utilities.templatetags.builtins.tags.tag
+
+## Filters
+
+The following custom template filters are available in NetBox.
+
+!!! info
+    These are loaded automatically by the template backend: You do _not_ need to include a `{% load %}` tag in your template to activate them.
+
+::: utilities.templatetags.builtins.filters.bettertitle
+
+::: utilities.templatetags.builtins.filters.content_type
+
+::: utilities.templatetags.builtins.filters.content_type_id
+
+::: utilities.templatetags.builtins.filters.meta
+
+::: utilities.templatetags.builtins.filters.placeholder
+
+::: utilities.templatetags.builtins.filters.render_json
+
+::: utilities.templatetags.builtins.filters.render_markdown
+
+::: utilities.templatetags.builtins.filters.render_yaml
+
+::: utilities.templatetags.builtins.filters.split
+
+::: utilities.templatetags.builtins.filters.tzoffset

+ 1 - 1
netbox/extras/tables.py

@@ -225,7 +225,7 @@ class ObjectJournalTable(NetBoxTable):
     )
     kind = columns.ChoiceFieldColumn()
     comments = tables.TemplateColumn(
-        template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}'
+        template_code='{{ value|markdown|truncatewords_html:50 }}'
     )
 
     class Meta(NetBoxTable.Meta):

+ 4 - 0
netbox/netbox/settings.py

@@ -352,6 +352,10 @@ TEMPLATES = [
         'DIRS': [TEMPLATES_DIR],
         'APP_DIRS': True,
         'OPTIONS': {
+            'builtins': [
+                'utilities.templatetags.builtins.filters',
+                'utilities.templatetags.builtins.tags',
+            ],
             'context_processors': [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',

+ 2 - 3
netbox/netbox/tables/columns.py

@@ -290,7 +290,7 @@ class TagColumn(tables.TemplateColumn):
     template_code = """
     {% load helpers %}
     {% for tag in value.all %}
-        {% tag tag url_name=url_name %}
+        {% tag tag url_name %}
     {% empty %}
         <span class="text-muted">&mdash;</span>
     {% endfor %}
@@ -414,9 +414,8 @@ class MarkdownColumn(tables.TemplateColumn):
     Render a Markdown string.
     """
     template_code = """
-    {% load helpers %}
     {% if value %}
-      {{ value|render_markdown }}
+      {{ value|markdown }}
     {% else %}
       &mdash;
     {% endif %}

+ 2 - 2
netbox/templates/circuits/provider.html

@@ -41,11 +41,11 @@
                     </tr>
                     <tr>
                         <th scope="row">NOC Contact</th>
-                        <td>{{ object.noc_contact|render_markdown|placeholder }}</td>
+                        <td>{{ object.noc_contact|markdown|placeholder }}</td>
                     </tr>
                     <tr>
                         <th scope="row">Admin Contact</th>
-                        <td>{{ object.admin_contact|render_markdown|placeholder }}</td>
+                        <td>{{ object.admin_contact|markdown|placeholder }}</td>
                     </tr>
                     <tr>
                         <th scope="row">Circuits</th>

+ 1 - 1
netbox/templates/dcim/platform.html

@@ -73,7 +73,7 @@
         NAPALM Arguments
       </h5>
       <div class="card-body">
-        <pre>{{ object.napalm_args|render_json }}</pre>
+        <pre>{{ object.napalm_args|json }}</pre>
       </div>
     </div>
     {% include 'inc/panels/custom_fields.html' %}

+ 1 - 1
netbox/templates/extras/htmx/report_result.html

@@ -60,7 +60,7 @@
                     <span class="muted">&mdash;</span>
                   {% endif %}
                 </td>
-                <td class="rendered-markdown">{{ message|render_markdown }}</td>
+                <td class="rendered-markdown">{{ message|markdown }}</td>
               </tr>
             {% endfor %}
           {% endfor %}

+ 1 - 1
netbox/templates/extras/htmx/script_result.html

@@ -22,7 +22,7 @@
           <tr>
             <td>{{ forloop.counter }}</td>
             <td>{% log_level log.status %}</td>
-            <td class="rendered-markdown">{{ log.message|render_markdown }}</td>
+            <td class="rendered-markdown">{{ log.message|markdown }}</td>
           </tr>
         {% empty %}
           <tr>

+ 1 - 1
netbox/templates/extras/inc/configcontext_data.html

@@ -1,5 +1,5 @@
 {% load helpers %}
 
 <div class="rendered-context-data">
-    <pre class="block">{% if format == 'json' %}{{ data|render_json }}{% elif format == 'yaml' %}{{ data|render_yaml }}{% else %}{{ data }}{% endif %}</pre>
+    <pre class="block">{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}</pre>
 </div>

+ 4 - 4
netbox/templates/extras/objectchange.html

@@ -98,8 +98,8 @@
                         {% endif %}
                     </span>
                 {% else %}
-                    <pre class="change-diff change-removed">{{ diff_removed|render_json }}</pre>
-                    <pre class="change-diff change-added">{{ diff_added|render_json }}</pre>
+                    <pre class="change-diff change-removed">{{ diff_removed|json }}</pre>
+                    <pre class="change-diff change-added">{{ diff_added|json }}</pre>
                 {% endif %}
             </div>
         </div>
@@ -114,7 +114,7 @@
             <div class="card-body">
             {% if object.prechange_data %}
                 <pre class="change-data">{% for k, v in object.prechange_data.items %}{% spaceless %}
-                    <span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|render_json }}</span>
+                    <span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
                 {% endspaceless %}{% endfor %}
                 </pre>
             {% elif non_atomic_change %}
@@ -133,7 +133,7 @@
             <div class="card-body">
                 {% if object.postchange_data %}
                     <pre class="change-data">{% for k, v in object.postchange_data.items %}{% spaceless %}
-                        <span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|render_json }}</span>
+                        <span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
                         {% endspaceless %}{% endfor %}
                     </pre>
                 {% else %}

+ 1 - 1
netbox/templates/extras/report.html

@@ -15,7 +15,7 @@
 {% block subtitle %}
   {% if report.description %}
     <div class="object-subtitle">
-      <div class="text-muted">{{ report.description|render_markdown }}</div>
+      <div class="text-muted">{{ report.description|markdown }}</div>
     </div>
   {% endif %}
 {% endblock subtitle %}

+ 1 - 1
netbox/templates/extras/report_list.html

@@ -40,7 +40,7 @@
                     <td>
                       {% include 'extras/inc/job_label.html' with result=report.result %}
                     </td>
-                    <td>{{ report.description|render_markdown|placeholder }}</td>
+                    <td>{{ report.description|markdown|placeholder }}</td>
                     <td class="text-end">
                       {% if report.result %}
                         <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>

+ 1 - 1
netbox/templates/extras/script.html

@@ -16,7 +16,7 @@
 
 {% block subtitle %}
   <div class="object-subtitle">
-    <div class="text-muted">{{ script.Meta.description|render_markdown }}</div>
+    <div class="text-muted">{{ script.Meta.description|markdown }}</div>
   </div>
 {% endblock subtitle %}
 

+ 1 - 1
netbox/templates/extras/script_list.html

@@ -40,7 +40,7 @@
                       {% include 'extras/inc/job_label.html' with result=script.result %}
                     </td>
                     <td>
-                      {{ script.Meta.description|render_markdown|placeholder }}
+                      {{ script.Meta.description|markdown|placeholder }}
                     </td>
                     {% if script.result %}
                       <td class="text-end">

+ 1 - 1
netbox/templates/extras/script_result.html

@@ -4,7 +4,7 @@
 {% block title %}{{ script }}{% endblock %}
 
 {% block subtitle %}
-  {{ script.Meta.description|render_markdown }}
+  {{ script.Meta.description|markdown }}
 {% endblock %}
 
 {% block header %}

+ 1 - 1
netbox/templates/extras/webhook.html

@@ -108,7 +108,7 @@
       </h5>
       <div class="card-body">
         {% if object.conditions %}
-          <pre>{{ object.conditions|render_json }}</pre>
+          <pre>{{ object.conditions|json }}</pre>
         {% else %}
           <p class="text-muted">None</p>
         {% endif %}

+ 1 - 1
netbox/templates/inc/panels/comments.html

@@ -6,7 +6,7 @@
   </h5>
   <div class="card-body">
     {% if object.comments %}
-      {{ object.comments|render_markdown }}
+      {{ object.comments|markdown }}
     {% else %}
       <span class="text-muted">None</span>
     {% endif %}

+ 2 - 2
netbox/templates/inc/panels/custom_fields.html

@@ -13,7 +13,7 @@
                             </td>
                             <td>
                                 {% if field.type == 'longtext' and value %}
-                                    {{ value|render_markdown }}
+                                    {{ value|markdown }}
                                 {% elif field.type == 'boolean' and value == True %}
                                     {% checkmark value true="True" %}
                                 {% elif field.type == 'boolean' and value == False %}
@@ -21,7 +21,7 @@
                                 {% elif field.type == 'url' and value %}
                                     <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
                                 {% elif field.type == 'json' and value %}
-                                    <pre>{{ value|render_json }}</pre>
+                                    <pre>{{ value|json }}</pre>
                                 {% elif field.type == 'multiselect' and value %}
                                     {{ value|join:", " }}
                                 {% elif field.type == 'object' and value %}

+ 0 - 0
netbox/utilities/templates/helpers/badge.html → netbox/utilities/templates/builtins/badge.html


+ 0 - 0
netbox/utilities/templates/helpers/checkmark.html → netbox/utilities/templates/builtins/checkmark.html


+ 1 - 1
netbox/utilities/templates/helpers/tag.html → netbox/utilities/templates/builtins/tag.html

@@ -1,3 +1,3 @@
 {% load helpers %}
 
-{% if url_name %}<a href="{% url url_name %}?tag={{ tag.slug }}">{% endif %}<span class="badge" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span>{% if url_name %}</a>{% endif %}
+{% if viewname %}<a href="{% url viewname %}?tag={{ tag.slug }}">{% endif %}<span class="badge" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span>{% if viewname %}</a>{% endif %}

+ 0 - 0
netbox/utilities/templatetags/builtins/__init__.py


+ 167 - 0
netbox/utilities/templatetags/builtins/filters.py

@@ -0,0 +1,167 @@
+import datetime
+import json
+import re
+
+import yaml
+from django import template
+from django.contrib.contenttypes.models import ContentType
+from django.utils.html import strip_tags
+from django.utils.safestring import mark_safe
+from markdown import markdown
+
+from netbox.config import get_config
+from utilities.markdown import StrikethroughExtension
+from utilities.utils import foreground_color
+
+register = template.Library()
+
+
+#
+# General
+#
+
+@register.filter()
+def bettertitle(value):
+    """
+    Alternative to the builtin title(). Ensures that the first letter of each word is uppercase but retains the
+    original case of all others.
+    """
+    return ' '.join([w[0].upper() + w[1:] for w in value.split()])
+
+
+@register.filter()
+def fgcolor(value, dark='000000', light='ffffff'):
+    """
+    Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format. The foreground
+    color with the better contrast is returned.
+
+    Args:
+        value: The background color
+        dark: The foreground color to use for light backgrounds
+        light: The foreground color to use for dark backgrounds
+    """
+    value = value.lower().strip('#')
+    if not re.match('^[0-9a-f]{6}$', value):
+        return ''
+    return f'#{foreground_color(value, dark, light)}'
+
+
+@register.filter()
+def meta(model, attr):
+    """
+    Return the specified Meta attribute of a model. This is needed because Django does not permit templates
+    to access attributes which begin with an underscore (e.g. _meta).
+
+    Args:
+        model: A Django model class or instance
+        attr: The attribute name
+    """
+    return getattr(model._meta, attr, '')
+
+
+@register.filter()
+def placeholder(value):
+    """
+    Render a muted placeholder if the value equates to False.
+    """
+    if value not in ('', None):
+        return value
+    placeholder = '<span class="text-muted">&mdash;</span>'
+    return mark_safe(placeholder)
+
+
+@register.filter()
+def split(value, separator=','):
+    """
+    Wrapper for Python's `split()` string method.
+
+    Args:
+        value: A string
+        separator: String on which the value will be split
+    """
+    return value.split(separator)
+
+
+@register.filter()
+def tzoffset(value):
+    """
+    Returns the hour offset of a given time zone using the current time.
+    """
+    return datetime.datetime.now(value).strftime('%z')
+
+
+#
+# Content types
+#
+
+@register.filter()
+def content_type(model):
+    """
+    Return the ContentType for the given object.
+    """
+    return ContentType.objects.get_for_model(model)
+
+
+@register.filter()
+def content_type_id(model):
+    """
+    Return the ContentType ID for the given object.
+    """
+    content_type = ContentType.objects.get_for_model(model)
+    if content_type:
+        return content_type.pk
+    return None
+
+
+#
+# Rendering
+#
+
+@register.filter('markdown', is_safe=True)
+def render_markdown(value):
+    """
+    Render a string as Markdown. This filter is invoked as "markdown":
+
+        {{ md_source_text|markdown }}
+    """
+    schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
+
+    # Strip HTML tags
+    value = strip_tags(value)
+
+    # Sanitize Markdown links
+    pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)'
+    value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
+
+    # Sanitize Markdown reference links
+    pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)'
+    value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
+
+    # Render Markdown
+    html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
+
+    # If the string is not empty wrap it in rendered-markdown to style tables
+    if html:
+        html = f'<div class="rendered-markdown">{html}</div>'
+
+    return mark_safe(html)
+
+
+@register.filter('json')
+def render_json(value):
+    """
+    Render a dictionary as formatted JSON. This filter is invoked as "json":
+
+        {{ data_dict|json }}
+    """
+    return json.dumps(value, ensure_ascii=False, indent=4, sort_keys=True)
+
+
+@register.filter('yaml')
+def render_yaml(value):
+    """
+    Render a dictionary as formatted YAML. This filter is invoked as "yaml":
+
+        {{ data_dict|yaml }}
+    """
+    return yaml.dump(json.loads(json.dumps(value)))

+ 54 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -0,0 +1,54 @@
+from django import template
+
+register = template.Library()
+
+
+@register.inclusion_tag('builtins/tag.html')
+def tag(value, viewname=None):
+    """
+    Display a tag, optionally linked to a filtered list of objects.
+
+    Args:
+        value: A Tag instance
+        viewname: If provided, the tag will be a hyperlink to the specified view's URL
+    """
+    return {
+        'tag': value,
+        'viewname': viewname,
+    }
+
+
+@register.inclusion_tag('builtins/badge.html')
+def badge(value, bg_class='secondary', show_empty=False):
+    """
+    Display the specified number as a badge.
+
+    Args:
+        value: The value to be displayed within the badge
+        bg_class: Bootstrap 5 background CSS name
+        show_empty: If true, display the badge even if value is None or zero
+    """
+    return {
+        'value': value,
+        'bg_class': bg_class,
+        'show_empty': show_empty,
+    }
+
+
+@register.inclusion_tag('builtins/checkmark.html')
+def checkmark(value, show_false=True, true='Yes', false='No'):
+    """
+    Display either a green checkmark or red X to indicate a boolean value.
+
+    Args:
+        value: True or False
+        show_false: Show false values
+        true: Text label for true values
+        false: Text label for false values
+    """
+    return {
+        'value': bool(value),
+        'show_false': show_false,
+        'true_label': true,
+        'false_label': false,
+    }

+ 1 - 165
netbox/utilities/templatetags/helpers.py

@@ -1,24 +1,17 @@
 import datetime
 import decimal
-import json
 import re
 from typing import Dict, Any
 
-import yaml
 from django import template
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
 from django.template.defaultfilters import date
 from django.urls import NoReverseMatch, reverse
 from django.utils import timezone
-from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
-from markdown import markdown
 
-from netbox.config import get_config
 from utilities.forms import get_selected_values, TableConfigForm
-from utilities.markdown import StrikethroughExtension
-from utilities.utils import foreground_color, get_viewname
+from utilities.utils import get_viewname
 
 register = template.Library()
 
@@ -27,88 +20,6 @@ register = template.Library()
 # Filters
 #
 
-@register.filter()
-def placeholder(value):
-    """
-    Render a muted placeholder if value equates to False.
-    """
-    if value not in ('', None):
-        return value
-    placeholder = '<span class="text-muted">&mdash;</span>'
-    return mark_safe(placeholder)
-
-
-@register.filter(is_safe=True)
-def render_markdown(value):
-    """
-    Render text as Markdown
-    """
-    schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
-
-    # Strip HTML tags
-    value = strip_tags(value)
-
-    # Sanitize Markdown links
-    pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)'
-    value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
-
-    # Sanitize Markdown reference links
-    pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)'
-    value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
-
-    # Render Markdown
-    html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
-
-    # If the string is not empty wrap it in rendered-markdown to style tables
-    if html:
-        html = f'<div class="rendered-markdown">{html}</div>'
-
-    return mark_safe(html)
-
-
-@register.filter()
-def render_json(value):
-    """
-    Render a dictionary as formatted JSON.
-    """
-    return json.dumps(value, ensure_ascii=False, indent=4, sort_keys=True)
-
-
-@register.filter()
-def render_yaml(value):
-    """
-    Render a dictionary as formatted YAML.
-    """
-    return yaml.dump(json.loads(json.dumps(value)))
-
-
-@register.filter()
-def meta(obj, attr):
-    """
-    Return the specified Meta attribute of a model. This is needed because Django does not permit templates
-    to access attributes which begin with an underscore (e.g. _meta).
-    """
-    return getattr(obj._meta, attr, '')
-
-
-@register.filter()
-def content_type(obj):
-    """
-    Return the ContentType for the given object.
-    """
-    return ContentType.objects.get_for_model(obj)
-
-
-@register.filter()
-def content_type_id(obj):
-    """
-    Return the ContentType ID for the given object.
-    """
-    content_type = ContentType.objects.get_for_model(obj)
-    if content_type:
-        return content_type.pk
-    return None
-
 
 @register.filter()
 def viewname(model, action):
@@ -133,14 +44,6 @@ def validated_viewname(model, action):
         return None
 
 
-@register.filter()
-def bettertitle(value):
-    """
-    Alternative to the builtin title(); uppercases words without replacing letters that are already uppercase.
-    """
-    return ' '.join([w[0].upper() + w[1:] for w in value.split()])
-
-
 @register.filter()
 def humanize_speed(speed):
     """
@@ -191,14 +94,6 @@ def simplify_decimal(value):
     return str(value).rstrip('0').rstrip('.')
 
 
-@register.filter()
-def tzoffset(value):
-    """
-    Returns the hour offset of a given time zone using the current time.
-    """
-    return datetime.datetime.now(value).strftime('%z')
-
-
 @register.filter(expects_localtime=True)
 def annotated_date(date_value):
     """
@@ -229,17 +124,6 @@ def annotated_now():
     return annotated_date(datetime.datetime.now(tz=tzinfo))
 
 
-@register.filter()
-def fgcolor(value):
-    """
-    Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format.
-    """
-    value = value.lower().strip('#')
-    if not re.match('^[0-9a-f]{6}$', value):
-        return ''
-    return f'#{foreground_color(value)}'
-
-
 @register.filter()
 def divide(x, y):
     """
@@ -276,14 +160,6 @@ def has_perms(user, permissions_list):
     return user.has_perms(permissions_list)
 
 
-@register.filter()
-def split(string, sep=','):
-    """
-    Split a string by the given value (default: comma)
-    """
-    return string.split(sep)
-
-
 @register.filter()
 def as_range(n):
     """
@@ -403,46 +279,6 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
     }
 
 
-@register.inclusion_tag('helpers/tag.html')
-def tag(tag, url_name=None):
-    """
-    Display a tag, optionally linked to a filtered list of objects.
-    """
-    return {
-        'tag': tag,
-        'url_name': url_name,
-    }
-
-
-@register.inclusion_tag('helpers/badge.html')
-def badge(value, bg_class='secondary', show_empty=False):
-    """
-    Display the specified number as a badge.
-    """
-    return {
-        'value': value,
-        'bg_class': bg_class,
-        'show_empty': show_empty,
-    }
-
-
-@register.inclusion_tag('helpers/checkmark.html')
-def checkmark(value, show_false=True, true='Yes', false='No'):
-    """
-    Display either a green checkmark or red X to indicate a boolean value.
-
-    :param show_false: Display a red X if the value is False
-    :param true: Text label for true value
-    :param false: Text label for false value
-    """
-    return {
-        'value': bool(value),
-        'show_false': show_false,
-        'true_label': true,
-        'false_label': false,
-    }
-
-
 @register.inclusion_tag('helpers/table_config_form.html')
 def table_config_form(table, table_name=None):
     return {