Просмотр исходного кода

20048 cleanup get_viewname URL resolution (#20050)

* #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
Arthur Hanson 6 месяцев назад
Родитель
Сommit
a585bc044e

+ 6 - 7
netbox/core/api/serializers_/object_types.py

@@ -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):

+ 7 - 9
netbox/extras/dashboard/widgets.py

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

+ 1 - 1
netbox/extras/tests/test_dashboard.py

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

+ 3 - 5
netbox/extras/views.py

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

+ 2 - 4
netbox/netbox/object_actions.py

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

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

@@ -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):

+ 2 - 4
netbox/netbox/tables/tables.py

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

+ 3 - 4
netbox/netbox/views/generic/bulk_views.py

@@ -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']:

+ 2 - 3
netbox/netbox/views/generic/object_views.py

@@ -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,

+ 3 - 5
netbox/templates/core/job/base.html

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

+ 9 - 15
netbox/templates/dcim/inc/cable_termination.html

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

+ 1 - 1
netbox/templates/extras/dashboard/widgets/objectlist.html

@@ -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 %}

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

@@ -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 %}

+ 3 - 5
netbox/templates/extras/object_imageattachments.html

@@ -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 %}
 

+ 12 - 13
netbox/templates/extras/tag.html

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

+ 1 - 1
netbox/templates/generic/object.html

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

+ 1 - 1
netbox/templates/htmx/quick_add.html

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

+ 4 - 5
netbox/templates/inc/panels/related_objects.html

@@ -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 %}

+ 3 - 5
netbox/templates/tenancy/object_contacts.html

@@ -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 %}

+ 3 - 5
netbox/utilities/forms/fields/dynamic.py

@@ -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():

+ 9 - 13
netbox/utilities/templatetags/buttons.py

@@ -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:

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

@@ -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):
     """

+ 2 - 4
netbox/utilities/templatetags/tabs.py

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

+ 18 - 1
netbox/utilities/views.py

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