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

View File

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

View File

@ -45,4 +45,4 @@ class ObjectListWidgetTests(TestCase):
mock_request = Request() mock_request = Request()
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config) widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request) 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.request import copy_safe_request
from utilities.rqworker import get_workers_for_queue from utilities.rqworker import get_workers_for_queue
from utilities.templatetags.builtins.filters import render_markdown 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 virtualization.models import VirtualMachine
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK from .constants import LOG_LEVEL_RANK
@ -1103,8 +1103,7 @@ class JournalEntryEditView(generic.ObjectEditView):
if not instance.assigned_object: if not instance.assigned_object:
return reverse('extras:journalentry_list') return reverse('extras:journalentry_list')
obj = instance.assigned_object obj = instance.assigned_object
viewname = get_viewname(obj, 'journal') return get_action_url(obj, action='journal', kwargs={'pk': obj.pk})
return reverse(viewname, kwargs={'pk': obj.pk})
@register_model_view(JournalEntry, 'delete') @register_model_view(JournalEntry, 'delete')
@ -1113,8 +1112,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
def get_return_url(self, request, instance): def get_return_url(self, request, instance):
obj = instance.assigned_object obj = instance.assigned_object
viewname = get_viewname(obj, 'journal') return get_action_url(obj, action='journal', kwargs={'pk': obj.pk})
return reverse(viewname, kwargs={'pk': obj.pk})
@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False) @register_model_view(JournalEntry, 'bulk_import', path='import', detail=False)

View File

@ -1,12 +1,11 @@
from django.template import loader from django.template import loader
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.models import ObjectType from core.models import ObjectType
from extras.models import ExportTemplate from extras.models import ExportTemplate
from utilities.querydict import prepare_cloned_fields from utilities.querydict import prepare_cloned_fields
from utilities.views import get_viewname from utilities.views import get_action_url
__all__ = ( __all__ = (
'AddObject', 'AddObject',
@ -43,12 +42,11 @@ class ObjectAction:
@classmethod @classmethod
def get_url(cls, obj): def get_url(cls, obj):
viewname = get_viewname(obj, action=cls.name)
kwargs = { kwargs = {
kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
} }
try: try:
return reverse(viewname, kwargs=kwargs) return get_action_url(obj, action=cls.name, kwargs=kwargs)
except NoReverseMatch: except NoReverseMatch:
return 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.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.templatetags.builtins.filters import render_markdown from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import get_viewname from utilities.views import get_action_url
__all__ = ( __all__ = (
'ActionsColumn', 'ActionsColumn',
@ -285,7 +285,7 @@ class ActionsColumn(tables.Column):
for idx, (action, attrs) in enumerate(self.actions.items()): for idx, (action, attrs) in enumerate(self.actions.items()):
permission = get_permission_for_model(model, attrs.permission) permission = get_permission_for_model(model, attrs.permission)
if attrs.permission is None or user.has_perm(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 # 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): 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.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
from django.db.models.fields.reverse_related import ManyToOneRel from django.db.models.fields.reverse_related import ManyToOneRel
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch from django.urls.exceptions import NoReverseMatch
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -23,7 +22,7 @@ from netbox.tables import columns
from utilities.html import highlight from utilities.html import highlight
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.string import title from utilities.string import title
from utilities.views import get_viewname from utilities.views import get_action_url
from .template_code import * from .template_code import *
__all__ = ( __all__ = (
@ -261,9 +260,8 @@ class NetBoxTable(BaseTable):
Return the base HTML request URL for embedded tables. Return the base HTML request URL for embedded tables.
""" """
if self.embedded: if self.embedded:
viewname = get_viewname(self._meta.model, action='list')
try: try:
return reverse(viewname) return get_action_url(self._meta.model, action='list')
except NoReverseMatch: except NoReverseMatch:
pass pass
return '' return ''

View File

@ -12,7 +12,6 @@ from django.db.models.fields.reverse_related import ManyToManyRel
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render 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.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from mptt.models import MPTTModel 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.query import reapply_model_ordering
from utilities.request import safe_for_redirect from utilities.request import safe_for_redirect
from utilities.tables import get_table_configs 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 .base import BaseMultiObjectView
from .mixins import ActionsMixin, TableMixin from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model from .utils import get_prerequisite_model
@ -130,7 +129,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
redirect_url = f'{request.path}?{query_params.urlencode()}' redirect_url = f'{request.path}?{query_params.urlencode()}'
if safe_for_redirect(redirect_url): if safe_for_redirect(redirect_url):
return 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 # Request handlers
@ -513,7 +512,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
if form.is_valid(): if form.is_valid():
logger.debug("Import form validation was successful") 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 indicated, defer this request to a background job & redirect the user
if form.cleaned_data['background_job']: 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.querydict import normalize_querydict, prepare_cloned_fields
from utilities.request import safe_for_redirect from utilities.request import safe_for_redirect
from utilities.tables import get_table_configs 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 .base import BaseObjectView
from .mixins import ActionsMixin, TableMixin from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model 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 this is an HTMX request, return only the rendered deletion form as modal content
if htmx_partial(request): if htmx_partial(request):
viewname = get_viewname(self.queryset.model, action='delete') form_url = get_action_url(self.queryset.model, action='delete', kwargs={'pk': obj.pk})
form_url = reverse(viewname, kwargs={'pk': obj.pk})
return render(request, 'htmx/delete_form.html', { return render(request, 'htmx/delete_form.html', {
'object': obj, 'object': obj,
'object_type': self.queryset.model._meta.verbose_name, 'object_type': self.queryset.model._meta.verbose_name,

View File

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

View File

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

View File

@ -7,6 +7,6 @@
</div> </div>
{% else %} {% else %}
<div class="text-danger text-center"> <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> </div>
{% endif %} {% endif %}

View File

@ -5,7 +5,7 @@
{% block breadcrumbs %} {% block breadcrumbs %}
{{ block.super }} {{ 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 %} {% endblock %}
{% block content %} {% block content %}

View File

@ -6,11 +6,9 @@
{% block extra_controls %} {% block extra_controls %}
{% if perms.extras.add_imageattachment %} {% 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={% action_url object 'image-attachments' pk=object.pk %}" class="btn btn-primary">
<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" %}
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Attach an Image" %} </a>
</a>
{% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

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

View File

@ -33,7 +33,7 @@ Context:
<ol class="breadcrumb" aria-label="breadcrumbs"> <ol class="breadcrumb" aria-label="breadcrumbs">
{% block breadcrumbs %} {% block breadcrumbs %}
<li class="breadcrumb-item"> <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> </li>
{% endblock breadcrumbs %} {% endblock breadcrumbs %}
</ol> </ol>

View File

@ -10,7 +10,7 @@
</div> </div>
<div class="modal-body row"> <div class="modal-body row">
<form <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" hx-target="#htmx-modal-content"
enctype="multipart/form-data" enctype="multipart/form-data"
> >

View File

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

View File

@ -4,10 +4,8 @@
{% block extra_controls %} {% block extra_controls %}
{% if perms.tenancy.add_contactassignment %} {% 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={% action_url object 'contacts' pk=object.pk %}" class="btn btn-primary">
<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" %}
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a contact" %} </a>
</a>
{% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -2,10 +2,9 @@ import django_filters
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.forms import BoundField from django.forms import BoundField
from django.urls import reverse
from utilities.forms import widgets from utilities.forms import widgets
from utilities.views import get_viewname from utilities.views import get_action_url
__all__ = ( __all__ = (
'DynamicChoiceField', 'DynamicChoiceField',
@ -173,13 +172,12 @@ class DynamicModelChoiceMixin:
# Set the data URL on the APISelect widget (if not already set) # Set the data URL on the APISelect widget (if not already set)
if not widget.attrs.get('data-url'): if not widget.attrs.get('data-url'):
viewname = get_viewname(self.queryset.model, action='list', rest_api=True) widget.attrs['data-url'] = get_action_url(self.queryset.model, action='list', rest_api=True)
widget.attrs['data-url'] = reverse(viewname)
# Include quick add? # Include quick add?
if self.quick_add: if self.quick_add:
widget.quick_add_context = { widget.quick_add_context = {
'url': reverse(get_viewname(self.model, 'add')), 'url': get_action_url(self.model, action='add'),
'params': {}, 'params': {},
} }
for k, v in self.quick_add_params.items(): 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 extras.models import Bookmark, ExportTemplate, Subscription
from netbox.models.features import NotificationsMixin from netbox.models.features import NotificationsMixin
from utilities.querydict import prepare_cloned_fields from utilities.querydict import prepare_cloned_fields
from utilities.views import get_viewname from utilities.views import get_action_url
__all__ = ( __all__ = (
'action_buttons', 'action_buttons',
@ -110,9 +110,8 @@ def subscribe_button(context, instance):
@register.inclusion_tag('buttons/clone.html') @register.inclusion_tag('buttons/clone.html')
def clone_button(instance): def clone_button(instance):
# Resolve URL path # Resolve URL path
viewname = get_viewname(instance, 'add')
try: try:
url = reverse(viewname) url = get_action_url(instance, action='add')
except NoReverseMatch: except NoReverseMatch:
return { return {
'url': None, 'url': None,
@ -128,8 +127,7 @@ def clone_button(instance):
# TODO: Remove in NetBox v4.6 # TODO: Remove in NetBox v4.6
@register.inclusion_tag('buttons/edit.html') @register.inclusion_tag('buttons/edit.html')
def edit_button(instance): def edit_button(instance):
viewname = get_viewname(instance, 'edit') url = get_action_url(instance, action='edit', kwargs={'pk': instance.pk})
url = reverse(viewname, kwargs={'pk': instance.pk})
return { return {
'url': url, 'url': url,
@ -140,8 +138,7 @@ def edit_button(instance):
# TODO: Remove in NetBox v4.6 # TODO: Remove in NetBox v4.6
@register.inclusion_tag('buttons/delete.html') @register.inclusion_tag('buttons/delete.html')
def delete_button(instance): def delete_button(instance):
viewname = get_viewname(instance, 'delete') url = get_action_url(instance, action='delete', kwargs={'pk': instance.pk})
url = reverse(viewname, kwargs={'pk': instance.pk})
return { return {
'url': url, 'url': url,
@ -152,8 +149,7 @@ def delete_button(instance):
# TODO: Remove in NetBox v4.6 # TODO: Remove in NetBox v4.6
@register.inclusion_tag('buttons/sync.html') @register.inclusion_tag('buttons/sync.html')
def sync_button(instance): def sync_button(instance):
viewname = get_viewname(instance, 'sync') url = get_action_url(instance, action='sync', kwargs={'pk': instance.pk})
url = reverse(viewname, kwargs={'pk': instance.pk})
return { return {
'label': _('Sync'), 'label': _('Sync'),
@ -169,7 +165,7 @@ def sync_button(instance):
@register.inclusion_tag('buttons/add.html') @register.inclusion_tag('buttons/add.html')
def add_button(model, action='add'): def add_button(model, action='add'):
try: try:
url = reverse(get_viewname(model, action)) url = get_action_url(model, action=action)
except NoReverseMatch: except NoReverseMatch:
url = None url = None
@ -183,7 +179,7 @@ def add_button(model, action='add'):
@register.inclusion_tag('buttons/import.html') @register.inclusion_tag('buttons/import.html')
def import_button(model, action='bulk_import'): def import_button(model, action='bulk_import'):
try: try:
url = reverse(get_viewname(model, action)) url = get_action_url(model, action=action)
except NoReverseMatch: except NoReverseMatch:
url = None url = None
@ -219,7 +215,7 @@ def export_button(context, model):
@register.inclusion_tag('buttons/bulk_edit.html', takes_context=True) @register.inclusion_tag('buttons/bulk_edit.html', takes_context=True)
def bulk_edit_button(context, model, action='bulk_edit', query_params=None): def bulk_edit_button(context, model, action='bulk_edit', query_params=None):
try: try:
url = reverse(get_viewname(model, action)) url = get_action_url(model, action=action)
if query_params: if query_params:
url = f'{url}?{query_params.urlencode()}' url = f'{url}?{query_params.urlencode()}'
except NoReverseMatch: 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) @register.inclusion_tag('buttons/bulk_delete.html', takes_context=True)
def bulk_delete_button(context, model, action='bulk_delete', query_params=None): def bulk_delete_button(context, model, action='bulk_delete', query_params=None):
try: try:
url = reverse(get_viewname(model, action)) url = get_action_url(model, action=action)
if query_params: if query_params:
url = f'{url}?{query_params.urlencode()}' url = f'{url}?{query_params.urlencode()}'
except NoReverseMatch: except NoReverseMatch:

View File

@ -4,13 +4,15 @@ from urllib.parse import quote
from django import template from django import template
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from django.utils.html import conditional_escape
from core.models import ObjectType from core.models import ObjectType
from utilities.forms import get_selected_values, TableConfigForm 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 from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT
__all__ = ( __all__ = (
'action_url',
'applied_filters', 'applied_filters',
'as_range', 'as_range',
'divide', 'divide',
@ -63,6 +65,125 @@ def validated_viewname(model, action):
return None 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() @register.filter()
def humanize_speed(speed): def humanize_speed(speed):
""" """

View File

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

View File

@ -20,6 +20,7 @@ __all__ = (
'GetReturnURLMixin', 'GetReturnURLMixin',
'ObjectPermissionRequiredMixin', 'ObjectPermissionRequiredMixin',
'ViewTab', 'ViewTab',
'get_action_url',
'get_viewname', 'get_viewname',
'register_model_view', 'register_model_view',
) )
@ -150,7 +151,7 @@ class GetReturnURLMixin:
# Attempt to dynamically resolve the list view for the object # Attempt to dynamically resolve the list view for the object
if hasattr(self, 'queryset'): if hasattr(self, 'queryset'):
try: try:
return reverse(get_viewname(self.queryset.model, 'list')) return get_action_url(self.queryset.model, action='list')
except NoReverseMatch: except NoReverseMatch:
pass pass
@ -282,6 +283,22 @@ def get_viewname(model, action=None, rest_api=False):
return viewname 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): 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 This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject