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 %} - - {% 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 %} - - {% 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 %} + +{% 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 {}, + })