diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py
index e1437c00e..b748b6f90 100644
--- a/netbox/extras/registry.py
+++ b/netbox/extras/registry.py
@@ -29,3 +29,4 @@ registry['model_features'] = {
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
}
registry['denormalized_fields'] = collections.defaultdict(list)
+registry['views'] = collections.defaultdict(dict)
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index ce80cec3e..0d519a8ba 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -13,6 +13,7 @@ from extras.utils import is_taggable, register_features
from netbox.signals import post_clean
from utilities.json import CustomFieldJSONEncoder
from utilities.utils import serialize_object
+from utilities.views import register_model_view
__all__ = (
'ChangeLoggingMixin',
@@ -292,3 +293,24 @@ def _register_features(sender, **kwargs):
feature for feature, cls in FEATURES_MAP if issubclass(sender, cls)
}
register_features(sender, features)
+
+ # Feature view registration
+ if issubclass(sender, JournalingMixin):
+ register_model_view(
+ sender,
+ 'journal',
+ 'netbox.views.generic.ObjectJournalView',
+ tab_label='Journal',
+ tab_badge=lambda x: x.journal_entries.count(),
+ tab_permission='extras.view_journalentry',
+ kwargs={'model': sender}
+ )
+ if issubclass(sender, ChangeLoggingMixin):
+ register_model_view(
+ sender,
+ 'changelog',
+ 'netbox.views.generic.ObjectChangeLogView',
+ tab_label='Changelog',
+ tab_permission='extras.view_objectchange',
+ kwargs={'model': sender}
+ )
diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html
index ef95ccdc0..2c3c76329 100644
--- a/netbox/templates/generic/object.html
+++ b/netbox/templates/generic/object.html
@@ -4,6 +4,7 @@
{% load helpers %}
{% load perms %}
{% load plugins %}
+{% load tabs %}
{% comment %}
Blocks:
@@ -83,34 +84,11 @@ Context:
{{ object|meta:"verbose_name"|bettertitle }}
- {# Include any additional tabs #}
+ {# Include any extra tabs passed by the view #}
{% block extra_tabs %}{% endblock %}
- {# Object journal #}
- {% if perms.extras.view_journalentry %}
- {% with journal_viewname=object|viewname:'journal' %}
- {% url journal_viewname pk=object.pk as journal_url %}
- {% if journal_url %}
-
-
- Journal {% badge object.journal_entries.count %}
-
-
- {% endif %}
- {% endwith %}
- {% endif %}
-
- {# Object changelog #}
- {% if perms.extras.view_objectchange %}
- {% with changelog_viewname=object|viewname:'changelog' %}
- {% url changelog_viewname pk=object.pk as changelog_url %}
- {% if changelog_url %}
-
- Change Log
-
- {% endif %}
- {% endwith %}
- {% endif %}
+ {# Include tabs for registered model views #}
+ {% model_view_tabs object %}
{% endblock tabs %}
diff --git a/netbox/utilities/templates/tabs/model_view_tabs.html b/netbox/utilities/templates/tabs/model_view_tabs.html
new file mode 100644
index 000000000..2c6a9046d
--- /dev/null
+++ b/netbox/utilities/templates/tabs/model_view_tabs.html
@@ -0,0 +1,8 @@
+{% for tab in tabs %}
+
+
+ {{ tab.label }}
+ {% if tab.badge_value %}{% badge tab.badge_value %}{% endif %}
+
+
+{% endfor %}
diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py
new file mode 100644
index 000000000..13b4a5f63
--- /dev/null
+++ b/netbox/utilities/templatetags/tabs.py
@@ -0,0 +1,50 @@
+from django import template
+from django.core.exceptions import ImproperlyConfigured
+from django.urls import reverse
+
+from extras.registry import registry
+
+register = template.Library()
+
+
+#
+# Object detail view tabs
+#
+
+@register.inclusion_tag('tabs/model_view_tabs.html', takes_context=True)
+def model_view_tabs(context, instance):
+ app_label = instance._meta.app_label
+ model_name = instance._meta.model_name
+ user = context['request'].user
+ tabs = []
+
+ # Retrieve registered views for this model
+ try:
+ views = registry['views'][app_label][model_name]
+ except KeyError:
+ # No views have been registered for this model
+ views = []
+
+ # Compile a list of tabs to be displayed in the UI
+ for view in views:
+ if view['tab_label'] and (not view['tab_permission'] or user.has_perm(view['tab_permission'])):
+
+ # Determine the value of the tab's badge (if any)
+ if view['tab_badge'] and callable(view['tab_badge']):
+ badge_value = view['tab_badge'](instance)
+ elif view['tab_badge']:
+ badge_value = view['tab_badge']
+ else:
+ badge_value = None
+
+ tabs.append({
+ 'name': view['name'],
+ 'url': reverse(f"{app_label}:{model_name}_{view['name']}", args=[instance.pk]),
+ 'label': view['tab_label'],
+ 'badge_value': badge_value,
+ 'is_active': context.get('active_tab') == view['name'],
+ })
+
+ return {
+ 'tabs': tabs,
+ }
diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py
new file mode 100644
index 000000000..3920889b3
--- /dev/null
+++ b/netbox/utilities/urls.py
@@ -0,0 +1,35 @@
+from django.urls import path
+from django.utils.module_loading import import_string
+from django.views.generic import View
+
+from extras.registry import registry
+
+
+def get_model_urls(app_label, model_name):
+ """
+ Return a list of URL paths for detail views registered to the given model.
+
+ Args:
+ app_label: App/plugin name
+ model_name: Model name
+ """
+ paths = []
+
+ # Retrieve registered views for this model
+ try:
+ views = registry['views'][app_label][model_name]
+ except KeyError:
+ # No views have been registered for this model
+ views = []
+
+ for view in views:
+ # Import the view class or function
+ callable = import_string(view['path'])
+ if issubclass(callable, View):
+ callable = callable.as_view()
+ # Create a path to the view
+ paths.append(
+ path(f"{view['name']}/", callable, name=f"{model_name}_{view['name']}", kwargs=view['kwargs'])
+ )
+
+ return paths
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 858e7b491..a4f5c79a9 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -3,8 +3,16 @@ from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
+from extras.registry import registry
from .permissions import resolve_permission
+__all__ = (
+ 'ContentTypePermissionRequiredMixin',
+ 'GetReturnURLMixin',
+ 'ObjectPermissionRequiredMixin',
+ 'register_model_view',
+)
+
#
# View Mixins
@@ -122,3 +130,33 @@ class GetReturnURLMixin:
# If all else fails, return home. Ideally this should never happen.
return reverse('home')
+
+
+def register_model_view(model, name, view_path, tab_label=None, tab_badge=None, tab_permission=None, kwargs=None):
+ """
+ Register a subview for a core model.
+
+ Args:
+ model: The Django model class with which this view will be associated
+ name: The name to register when creating a URL path
+ view_path: A dotted path to the view class or function (e.g. 'myplugin.views.FooView')
+ tab_label: The label to display for the view's tab under the model view (optional)
+ tab_badge: A static value or callable to display a badge within the view's tab (optional). If a callable is
+ specified, it must accept the current object as its single positional argument.
+ tab_permission: The name of the permission required to display the tab (optional)
+ kwargs: A dictionary of keyword arguments to send to the view (optional)
+ """
+ app_label = model._meta.app_label
+ model_name = model._meta.model_name
+
+ if model_name not in registry['views'][app_label]:
+ registry['views'][app_label][model_name] = []
+
+ registry['views'][app_label][model_name].append({
+ 'name': name,
+ 'path': view_path,
+ 'tab_label': tab_label,
+ 'tab_badge': tab_badge,
+ 'tab_permission': tab_permission,
+ 'kwargs': kwargs or {},
+ })