20048 cleanup get_viewname URL resolution (#20050)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run

* #20048 add get_action_url utility function

* #20048 add get_action_url utility function

* #20048 add get_action_url utility function

* #20048 add get_action_url utility function

* #20048 add get_action_url utility function

* #20048 action_url template tag

* #20048 action_url template tag

* #20048 fix test

* #20048 review feedback

* #20048 fix tags
This commit is contained in:
Arthur Hanson 2025-08-11 05:38:19 -07:00 committed by GitHub
parent 1242ad68f7
commit a585bc044e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 220 additions and 115 deletions

View File

@ -1,13 +1,13 @@
import inspect
from django.urls import NoReverseMatch, reverse
from django.urls import NoReverseMatch
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.models import ObjectType
from netbox.api.serializers import BaseModelSerializer
from utilities.views import get_viewname
from utilities.views import get_action_url
__all__ = (
'ObjectTypeSerializer',
@ -34,11 +34,10 @@ class ObjectTypeSerializer(BaseModelSerializer):
def get_rest_api_endpoint(self, obj):
if not (model := obj.model_class()):
return
if viewname := get_viewname(model, action='list', rest_api=True):
try:
return reverse(viewname)
except NoReverseMatch:
return
try:
return get_action_url(model, action='list', rest_api=True)
except NoReverseMatch:
return
@extend_schema_field(OpenApiTypes.STR)
def get_description(self, obj):

View File

@ -11,7 +11,7 @@ from django.conf import settings
from django.core.cache import cache
from django.db.models import Model
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse
from django.urls import NoReverseMatch, resolve
from django.utils.translation import gettext as _
from core.models import ObjectType
@ -21,7 +21,7 @@ from utilities.permissions import get_permission_for_model
from utilities.proxy import resolve_proxies
from utilities.querydict import dict_to_querydict
from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import get_viewname
from utilities.views import get_action_url
from .utils import register_widget
__all__ = (
@ -53,9 +53,9 @@ def object_list_widget_supports_model(model: Model) -> bool:
"""
def can_resolve_model_list_view(model: Model) -> bool:
try:
reverse(get_viewname(model, action='list'))
get_action_url(model, action='list')
return True
except Exception:
except NoReverseMatch:
return False
tests = [
@ -206,7 +206,7 @@ class ObjectCountsWidget(DashboardWidget):
permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission):
try:
url = reverse(get_viewname(model, 'list'))
url = get_action_url(model, action='list')
except NoReverseMatch:
url = None
qs = model.objects.restrict(request.user, 'view')
@ -275,15 +275,13 @@ class ObjectListWidget(DashboardWidget):
logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
return
viewname = get_viewname(model, action='list')
# Evaluate user's permission. Note that this controls only whether the HTMX element is
# embedded on the page: The view itself will also evaluate permissions separately.
permission = get_permission_for_model(model, 'view')
has_permission = request.user.has_perm(permission)
try:
htmx_url = reverse(viewname)
htmx_url = get_action_url(model, action='list')
except NoReverseMatch:
htmx_url = None
parameters = self.config.get('url_params') or {}
@ -297,7 +295,7 @@ class ObjectListWidget(DashboardWidget):
except ValueError:
pass
return render_to_string(self.template_name, {
'viewname': viewname,
'model_name': model_name,
'has_permission': has_permission,
'htmx_url': htmx_url,
})

View File

@ -45,4 +45,4 @@ class ObjectListWidgetTests(TestCase):
mock_request = Request()
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request)
self.assertTrue('Unable to load content. Invalid view name:' in rendered)
self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)

View File

@ -31,7 +31,7 @@ from utilities.querydict import normalize_querydict
from utilities.request import copy_safe_request
from utilities.rqworker import get_workers_for_queue
from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, get_action_url, register_model_view
from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK
@ -1103,8 +1103,7 @@ class JournalEntryEditView(generic.ObjectEditView):
if not instance.assigned_object:
return reverse('extras:journalentry_list')
obj = instance.assigned_object
viewname = get_viewname(obj, 'journal')
return reverse(viewname, kwargs={'pk': obj.pk})
return get_action_url(obj, action='journal', kwargs={'pk': obj.pk})
@register_model_view(JournalEntry, 'delete')
@ -1113,8 +1112,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
def get_return_url(self, request, instance):
obj = instance.assigned_object
viewname = get_viewname(obj, 'journal')
return reverse(viewname, kwargs={'pk': obj.pk})
return get_action_url(obj, action='journal', kwargs={'pk': obj.pk})
@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False)

View File

@ -1,12 +1,11 @@
from django.template import loader
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.models import ExportTemplate
from utilities.querydict import prepare_cloned_fields
from utilities.views import get_viewname
from utilities.views import get_action_url
__all__ = (
'AddObject',
@ -43,12 +42,11 @@ class ObjectAction:
@classmethod
def get_url(cls, obj):
viewname = get_viewname(obj, action=cls.name)
kwargs = {
kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
}
try:
return reverse(viewname, kwargs=kwargs)
return get_action_url(obj, action=cls.name, kwargs=kwargs)
except NoReverseMatch:
return

View File

@ -21,7 +21,7 @@ from extras.choices import CustomFieldTypeChoices
from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model
from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import get_viewname
from utilities.views import get_action_url
__all__ = (
'ActionsColumn',
@ -285,7 +285,7 @@ class ActionsColumn(tables.Column):
for idx, (action, attrs) in enumerate(self.actions.items()):
permission = get_permission_for_model(model, attrs.permission)
if attrs.permission is None or user.has_perm(permission):
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
url = get_action_url(model, action=action, kwargs={'pk': record.pk})
# Render a separate button if a) only one action exists, or b) if split_actions is True
if len(self.actions) == 1 or (self.split_actions and idx == 0):

View File

@ -8,7 +8,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField
from django.db.models.fields.reverse_related import ManyToOneRel
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
@ -23,7 +22,7 @@ from netbox.tables import columns
from utilities.html import highlight
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.string import title
from utilities.views import get_viewname
from utilities.views import get_action_url
from .template_code import *
__all__ = (
@ -261,9 +260,8 @@ class NetBoxTable(BaseTable):
Return the base HTML request URL for embedded tables.
"""
if self.embedded:
viewname = get_viewname(self._meta.model, action='list')
try:
return reverse(viewname)
return get_action_url(self._meta.model, action='list')
except NoReverseMatch:
pass
return ''

View File

@ -12,7 +12,6 @@ from django.db.models.fields.reverse_related import ManyToManyRel
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from mptt.models import MPTTModel
@ -34,7 +33,7 @@ from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering
from utilities.request import safe_for_redirect
from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname
from utilities.views import GetReturnURLMixin, get_action_url
from .base import BaseMultiObjectView
from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model
@ -130,7 +129,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
redirect_url = f'{request.path}?{query_params.urlencode()}'
if safe_for_redirect(redirect_url):
return redirect(redirect_url)
return redirect(get_viewname(self.queryset.model, 'list'))
return redirect(get_action_url(self.queryset.model, action='list'))
#
# Request handlers
@ -513,7 +512,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
if form.is_valid():
logger.debug("Import form validation was successful")
redirect_url = reverse(get_viewname(model, action='list'))
redirect_url = get_action_url(model, action='list')
# If indicated, defer this request to a background job & redirect the user
if form.cleaned_data['background_job']:

View File

@ -25,7 +25,7 @@ from utilities.permissions import get_permission_for_model
from utilities.querydict import normalize_querydict, prepare_cloned_fields
from utilities.request import safe_for_redirect
from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname
from utilities.views import GetReturnURLMixin, get_action_url
from .base import BaseObjectView
from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model
@ -436,8 +436,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
# If this is an HTMX request, return only the rendered deletion form as modal content
if htmx_partial(request):
viewname = get_viewname(self.queryset.model, action='delete')
form_url = reverse(viewname, kwargs={'pk': obj.pk})
form_url = get_action_url(self.queryset.model, action='delete', kwargs={'pk': obj.pk})
return render(request, 'htmx/delete_form.html', {
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,

View File

@ -10,11 +10,9 @@
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object|meta:"verbose_name_plural"|bettertitle }}</a>
</li>
{% with parent_jobs_viewname=object.object|viewname:"jobs" %}
<li class="breadcrumb-item">
<a href="{% url parent_jobs_viewname pk=object.object.pk %}">{{ object.object }}</a>
</li>
{% endwith %}
<li class="breadcrumb-item">
<a href="{% action_url object.object 'jobs' pk=object.object.pk %}">{{ object.object }}</a>
</li>
{% else %}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?name={{ object.name|urlencode }}">{{ object.name }}</a>

View File

@ -23,11 +23,9 @@
{{ term.device|linkify }}
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
{{ term|linkify }}
{% with trace_url=term|viewname:"trace" %}
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% endwith %}
<a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% if not forloop.last %}<br/>{% endif %}
{% endfor %}
</td>
@ -47,11 +45,9 @@
<td>
{% for term in terminations %}
{{ term|linkify }}
{% with trace_url=term|viewname:"trace" %}
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% endwith %}
<a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% if not forloop.last %}<br/>{% endif %}
{% endfor %}
</td>
@ -67,11 +63,9 @@
<td>
{% for term in terminations %}
{{ term.circuit|linkify }} ({{ term }})
{% with trace_url=term|viewname:"trace" %}
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% endwith %}
<a href="{% action_url term 'trace' pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
{% if not forloop.last %}<br/>{% endif %}
{% endfor %}
</td>

View File

@ -7,6 +7,6 @@
</div>
{% else %}
<div class="text-danger text-center">
<i class="mdi mdi-alert"></i> {% trans "Unable to load content. Invalid view name" %}: <span class="font-monospace">{{ viewname }}</span>
<i class="mdi mdi-alert"></i> {% trans "Unable to load content. Could not resolve list URL for:" %} <span class="font-monospace">{{ model_name }}</span>
</div>
{% endif %}

View File

@ -5,7 +5,7 @@
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url object.assigned_object|viewname:'journal' pk=object.assigned_object.pk %}">{{ object.assigned_object }}</a></li>
<li class="breadcrumb-item"><a href="{% action_url object.assigned_object 'journal' pk=object.assigned_object.pk %}">{{ object.assigned_object }}</a></li>
{% endblock %}
{% block content %}

View File

@ -6,11 +6,9 @@
{% block extra_controls %}
{% if perms.extras.add_imageattachment %}
{% with viewname=object|viewname:"image-attachments" %}
<a href="{% url 'extras:imageattachment_add' %}?object_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Attach an Image" %}
</a>
{% endwith %}
<a href="{% url 'extras:imageattachment_add' %}?object_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% action_url object 'image-attachments' pk=object.pk %}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Attach an Image" %}
</a>
{% endif %}
{% endblock %}

View File

@ -61,19 +61,18 @@
<h2 class="card-header">{% trans "Tagged Item Types" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for object_type in object_types %}
{% with viewname=object_type.content_type.model_class|validated_viewname:"list" %}
{% if viewname %}
<a href="{% url viewname %}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</a>
{% else %}
<li class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</li>
{% endif %}
{% endwith %}
{% action_url object_type.content_type.model_class 'list' as list_url %}
{% if list_url %}
<a href="{{ list_url }}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</a>
{% else %}
<li class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</li>
{% endif %}
{% endfor %}
</ul>
</div>

View File

@ -33,7 +33,7 @@ Context:
<ol class="breadcrumb" aria-label="breadcrumbs">
{% block breadcrumbs %}
<li class="breadcrumb-item">
<a href="{% url object|viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a>
<a href="{% action_url object 'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a>
</li>
{% endblock breadcrumbs %}
</ol>

View File

@ -10,7 +10,7 @@
</div>
<div class="modal-body row">
<form
hx-post="{% url model|viewname:"add" %}?_quickadd=True&target={{ request.GET.target }}"
hx-post="{% action_url model 'add' %}?_quickadd=True&target={{ request.GET.target }}"
hx-target="#htmx-modal-content"
enctype="multipart/form-data"
>

View File

@ -5,9 +5,9 @@
<h2 class="card-header">{% trans "Related Objects" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for qs, filter_param in related_models %}
{% with viewname=qs.model|validated_viewname:"list" %}
{% if viewname is not None %}
<a href="{% url viewname %}?{{ filter_param }}={{ object.pk }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{% action_url qs.model 'list' as list_url %}
{% if list_url %}
<a href="{{ list_url }}?{{ filter_param }}={{ object.pk }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %}
{% if count %}
@ -17,8 +17,7 @@
{% endif %}
{% endwith %}
</a>
{% endif %}
{% endwith %}
{% endif %}
{% empty %}
<span class="list-group-item text-muted">{% trans "None" %}</span>
{% endfor %}

View File

@ -4,10 +4,8 @@
{% block extra_controls %}
{% if perms.tenancy.add_contactassignment %}
{% with viewname=object|viewname:"contacts" %}
<a href="{% url 'tenancy:contactassignment_add' %}?object_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a contact" %}
</a>
{% endwith %}
<a href="{% url 'tenancy:contactassignment_add' %}?object_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% action_url object 'contacts' pk=object.pk %}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a contact" %}
</a>
{% endif %}
{% endblock %}

View File

@ -2,10 +2,9 @@ import django_filters
from django import forms
from django.conf import settings
from django.forms import BoundField
from django.urls import reverse
from utilities.forms import widgets
from utilities.views import get_viewname
from utilities.views import get_action_url
__all__ = (
'DynamicChoiceField',
@ -173,13 +172,12 @@ class DynamicModelChoiceMixin:
# Set the data URL on the APISelect widget (if not already set)
if not widget.attrs.get('data-url'):
viewname = get_viewname(self.queryset.model, action='list', rest_api=True)
widget.attrs['data-url'] = reverse(viewname)
widget.attrs['data-url'] = get_action_url(self.queryset.model, action='list', rest_api=True)
# Include quick add?
if self.quick_add:
widget.quick_add_context = {
'url': reverse(get_viewname(self.model, 'add')),
'url': get_action_url(self.model, action='add'),
'params': {},
}
for k, v in self.quick_add_params.items():

View File

@ -8,7 +8,7 @@ from core.models import ObjectType
from extras.models import Bookmark, ExportTemplate, Subscription
from netbox.models.features import NotificationsMixin
from utilities.querydict import prepare_cloned_fields
from utilities.views import get_viewname
from utilities.views import get_action_url
__all__ = (
'action_buttons',
@ -110,9 +110,8 @@ def subscribe_button(context, instance):
@register.inclusion_tag('buttons/clone.html')
def clone_button(instance):
# Resolve URL path
viewname = get_viewname(instance, 'add')
try:
url = reverse(viewname)
url = get_action_url(instance, action='add')
except NoReverseMatch:
return {
'url': None,
@ -128,8 +127,7 @@ def clone_button(instance):
# TODO: Remove in NetBox v4.6
@register.inclusion_tag('buttons/edit.html')
def edit_button(instance):
viewname = get_viewname(instance, 'edit')
url = reverse(viewname, kwargs={'pk': instance.pk})
url = get_action_url(instance, action='edit', kwargs={'pk': instance.pk})
return {
'url': url,
@ -140,8 +138,7 @@ def edit_button(instance):
# TODO: Remove in NetBox v4.6
@register.inclusion_tag('buttons/delete.html')
def delete_button(instance):
viewname = get_viewname(instance, 'delete')
url = reverse(viewname, kwargs={'pk': instance.pk})
url = get_action_url(instance, action='delete', kwargs={'pk': instance.pk})
return {
'url': url,
@ -152,8 +149,7 @@ def delete_button(instance):
# TODO: Remove in NetBox v4.6
@register.inclusion_tag('buttons/sync.html')
def sync_button(instance):
viewname = get_viewname(instance, 'sync')
url = reverse(viewname, kwargs={'pk': instance.pk})
url = get_action_url(instance, action='sync', kwargs={'pk': instance.pk})
return {
'label': _('Sync'),
@ -169,7 +165,7 @@ def sync_button(instance):
@register.inclusion_tag('buttons/add.html')
def add_button(model, action='add'):
try:
url = reverse(get_viewname(model, action))
url = get_action_url(model, action=action)
except NoReverseMatch:
url = None
@ -183,7 +179,7 @@ def add_button(model, action='add'):
@register.inclusion_tag('buttons/import.html')
def import_button(model, action='bulk_import'):
try:
url = reverse(get_viewname(model, action))
url = get_action_url(model, action=action)
except NoReverseMatch:
url = None
@ -219,7 +215,7 @@ def export_button(context, model):
@register.inclusion_tag('buttons/bulk_edit.html', takes_context=True)
def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
try:
url = reverse(get_viewname(model, action))
url = get_action_url(model, action=action)
if query_params:
url = f'{url}?{query_params.urlencode()}'
except NoReverseMatch:
@ -236,7 +232,7 @@ def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
@register.inclusion_tag('buttons/bulk_delete.html', takes_context=True)
def bulk_delete_button(context, model, action='bulk_delete', query_params=None):
try:
url = reverse(get_viewname(model, action))
url = get_action_url(model, action=action)
if query_params:
url = f'{url}?{query_params.urlencode()}'
except NoReverseMatch:

View File

@ -4,13 +4,15 @@ from urllib.parse import quote
from django import template
from django.urls import NoReverseMatch, reverse
from django.utils.html import conditional_escape
from core.models import ObjectType
from utilities.forms import get_selected_values, TableConfigForm
from utilities.views import get_viewname
from utilities.views import get_viewname, get_action_url
from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT
__all__ = (
'action_url',
'applied_filters',
'as_range',
'divide',
@ -63,6 +65,125 @@ def validated_viewname(model, action):
return None
class ActionURLNode(template.Node):
"""Template node for the {% action_url %} template tag."""
child_nodelists = ()
def __init__(self, model, action, kwargs, asvar=None):
self.model = model
self.action = action
self.kwargs = kwargs
self.asvar = asvar
def __repr__(self):
return (
f"<{self.__class__.__qualname__} "
f"model='{self.model}' "
f"action='{self.action}' "
f"kwargs={repr(self.kwargs)} "
f"as={repr(self.asvar)}>"
)
def render(self, context):
"""
Render the action URL node.
Args:
context: The template context
Returns:
The resolved URL or empty string if using 'as' syntax
Raises:
NoReverseMatch: If the URL cannot be resolved and not using 'as' syntax
"""
# Resolve model and kwargs from context
model = self.model.resolve(context)
kwargs = {k: v.resolve(context) for k, v in self.kwargs.items()}
# Get the action URL using the utility function
try:
url = get_action_url(model, action=self.action, kwargs=kwargs)
except NoReverseMatch:
if self.asvar is None:
raise
url = ""
# Handle variable assignment or return escaped URL
if self.asvar:
context[self.asvar] = url
return ""
return conditional_escape(url) if context.autoescape else url
@register.tag
def action_url(parser, token):
"""
Return an absolute URL matching the given model and action.
This is a way to define links that aren't tied to a particular URL
configuration::
{% action_url model "action_name" %}
or
{% action_url model "action_name" pk=object.pk %}
or
{% action_url model "action_name" pk=object.pk as variable_name %}
The first argument is a model or instance. The second argument is the action name.
Additional keyword arguments can be passed for URL parameters.
For example, if you have a Device model and want to link to its edit action::
{% action_url device "edit" %}
This will generate a URL like ``/dcim/devices/123/edit/``.
You can also pass additional parameters::
{% action_url device "journal" pk=device.pk %}
Or assign the URL to a variable::
{% action_url device "edit" as edit_url %}
"""
# Parse the token contents
bits = token.split_contents()
if len(bits) < 3:
raise template.TemplateSyntaxError(
f"'{bits[0]}' takes at least two arguments, a model and an action."
)
# Extract model and action
model = parser.compile_filter(bits[1])
action = bits[2].strip('"\'') # Remove quotes from literal string
kwargs = {}
asvar = None
bits = bits[3:]
# Handle 'as' syntax for variable assignment
if len(bits) >= 2 and bits[-2] == "as":
asvar = bits[-1]
bits = bits[:-2]
# Parse remaining arguments as kwargs
for bit in bits:
if '=' not in bit:
raise template.TemplateSyntaxError(
f"'{token.contents.split()[0]}' keyword arguments must be in the format 'name=value'"
)
name, value = bit.split('=', 1)
kwargs[name] = parser.compile_filter(value)
return ActionURLNode(model, action, kwargs, asvar)
@register.filter()
def humanize_speed(speed):
"""

View File

@ -1,10 +1,9 @@
from django import template
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.module_loading import import_string
from netbox.registry import registry
from utilities.views import get_viewname
from utilities.views import get_action_url
__all__ = (
'model_view_tabs',
@ -39,10 +38,9 @@ def model_view_tabs(context, instance):
continue
if attrs := tab.render(instance):
viewname = get_viewname(instance, action=config['name'])
active_tab = context.get('tab')
try:
url = reverse(viewname, args=[instance.pk])
url = get_action_url(instance, action=config['name'], kwargs={'pk': instance.pk})
except NoReverseMatch:
# No URL has been registered for this view; skip
continue

View File

@ -20,6 +20,7 @@ __all__ = (
'GetReturnURLMixin',
'ObjectPermissionRequiredMixin',
'ViewTab',
'get_action_url',
'get_viewname',
'register_model_view',
)
@ -150,7 +151,7 @@ class GetReturnURLMixin:
# Attempt to dynamically resolve the list view for the object
if hasattr(self, 'queryset'):
try:
return reverse(get_viewname(self.queryset.model, 'list'))
return get_action_url(self.queryset.model, action='list')
except NoReverseMatch:
pass
@ -282,6 +283,22 @@ def get_viewname(model, action=None, rest_api=False):
return viewname
def get_action_url(model, action=None, rest_api=False, kwargs=None):
"""
Return the URL for the given model and action, if valid; otherwise raise NoReverseMatch.
Will defer to _get_action_url() on the model if it exists.
:param model: The model or instance to which the URL belongs
:param action: A string indicating the desired action (if any); e.g. "add" or "list"
:param rest_api: A boolean indicating whether this is a REST API action
:param kwargs: A dictionary of keyword arguments for the view to include when resolving its URL path (optional)
"""
if hasattr(model, '_get_action_url'):
return model._get_action_url(action, rest_api, kwargs)
return reverse(get_viewname(model, action, rest_api), kwargs=kwargs)
def register_model_view(model, name='', path=None, detail=True, kwargs=None):
"""
This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject