From 87996465323f207218b5d287fbc3a90d26459d47 Mon Sep 17 00:00:00 2001 From: Jan Krupa <> Date: Thu, 26 Mar 2026 09:50:19 +0000 Subject: [PATCH 1/6] Add related object tab views and templates - Add tab_views.py with combined and typed tab view factories - Combined tab shows all linked custom objects with actions, tags, column config, quick search - Typed tabs show per-COT filtered view with bulk actions and per-field filters - Auto-discover referenced models from app registry (never call get_model during registration) - Support CO-to-CO tabs (custom objects referencing other custom objects) - Badge callables use OR + distinct to avoid double-counting - Add combined_tab.html and typed_tab.html templates --- netbox_custom_objects/tab_views.py | 533 ++++++++++++++++++ .../tabs/combined_tab.html | 142 +++++ .../netbox_custom_objects/tabs/typed_tab.html | 72 +++ 3 files changed, 747 insertions(+) create mode 100644 netbox_custom_objects/tab_views.py create mode 100644 netbox_custom_objects/templates/netbox_custom_objects/tabs/combined_tab.html create mode 100644 netbox_custom_objects/templates/netbox_custom_objects/tabs/typed_tab.html diff --git a/netbox_custom_objects/tab_views.py b/netbox_custom_objects/tab_views.py new file mode 100644 index 00000000..dcec61ec --- /dev/null +++ b/netbox_custom_objects/tab_views.py @@ -0,0 +1,533 @@ +""" +Related-object tab views for netbox-custom-objects. + +Two tab types: +1. Combined "Custom Objects" tab — shows all linked custom objects in a simple table. +2. Per-COT typed tabs — opt-in via show_tab=True, with type-specific columns, filters, bulk actions. + +CRITICAL: During registration, never call get_model() or apps.get_model() for dynamic CO models. +Read from app_config.get_models() instead, as each get_model() cache miss re-registers +journal/changelog views and can corrupt cross-reference models. +See: CESNET/netbox-custom-objects-tab#3 +""" + +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import Any + +from django.apps import apps +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from django.db.utils import OperationalError, ProgrammingError +from django.shortcuts import get_object_or_404, render +from django.utils.translation import gettext_lazy as _ +from django.views.generic import View +from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices +from netbox.forms import NetBoxModelFilterSetForm +from netbox.registry import registry +from netbox.tables import BaseTable +from utilities.forms.fields import TagFilterField +from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.views import ViewTab, register_model_view + +import django_tables2 as tables2 + +from netbox_custom_objects import field_types +from netbox_custom_objects.constants import APP_LABEL +from netbox_custom_objects.filtersets import get_filterset_class +from netbox_custom_objects.models import CustomObjectTypeField +from netbox_custom_objects.tables import CustomObjectTable + +logger = logging.getLogger('netbox_custom_objects') + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_CO_BASE_TEMPLATE = 'netbox_custom_objects/customobject.html' + + +def _get_base_template(instance): + """Return the correct base_template for an object's detail page.""" + if instance._meta.app_label == APP_LABEL: + return _CO_BASE_TEMPLATE + return f'{instance._meta.app_label}/{instance._meta.model_name}.html' + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +@dataclass +class LinkedCustomObject: + custom_object: Any + field: CustomObjectTypeField + + +def _iter_linked_fields(instance): + """Yield (field, model, filter_kwargs) for every CO field referencing *instance*.""" + content_type = ContentType.objects.get_for_model(instance._meta.model) + fields = CustomObjectTypeField.objects.filter( + related_object_type=content_type, + type__in=[CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT], + ).select_related('custom_object_type') + + for field in fields: + try: + model = field.custom_object_type.get_model() + except Exception: + logger.debug('could not get model for COT %s', field.custom_object_type_id) + continue + + if field.type == CustomFieldTypeChoices.TYPE_OBJECT: + yield field, model, {f'{field.name}_id': instance.pk} + elif field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + yield field, model, {field.name: instance.pk} + + +# =========================================================================== +# Combined tab +# =========================================================================== + +class CustomObjectsTabTable(BaseTable): + """Table class for column-preference machinery on the combined tab.""" + + type = tables2.Column(verbose_name=_('Type'), orderable=False) + object = tables2.Column(verbose_name=_('Object'), orderable=False) + value = tables2.Column(verbose_name=_('Value'), orderable=False) + field = tables2.Column(verbose_name=_('Field'), orderable=False) + tags = tables2.Column(verbose_name=_('Tags'), orderable=False) + actions = tables2.Column(verbose_name='', orderable=False) + + exempt_columns = ('actions',) + + class Meta(BaseTable.Meta): + fields = ('type', 'object', 'value', 'field', 'tags', 'actions') + default_columns = ('type', 'object', 'value', 'field', 'tags', 'actions') + + +_MAX_MULTIOBJECT_DISPLAY = 3 + + +def _get_field_value(obj, field): + """Return the value stored in *field* on *obj*, for the Value column.""" + if field.type == CustomFieldTypeChoices.TYPE_OBJECT: + return getattr(obj, field.name, None) + elif field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + qs = getattr(obj, field.name, None) + if qs is None: + return [] + return list(qs.all()[:_MAX_MULTIOBJECT_DISPLAY + 1]) + return None + + +def _count_linked(instance): + """Badge callable. Returns None when 0 so hide_if_empty works.""" + total = 0 + for _field, model, fk in _iter_linked_fields(instance): + try: + total += model.objects.filter(**fk).count() + except Exception: + pass + return total or None + + +def _get_linked_objects(instance): + """Return list of LinkedCustomObject for all COs referencing *instance*.""" + results = [] + for field, model, fk in _iter_linked_fields(instance): + try: + for obj in model.objects.filter(**fk): + results.append(LinkedCustomObject(custom_object=obj, field=field)) + except Exception: + pass + return results + + +def _make_combined_tab_view(model_class): + """Factory: returns a View subclass for the combined Custom Objects tab.""" + + class CombinedTabView(View): + tab = ViewTab( + label=_('Custom Objects'), + badge=_count_linked, + weight=2000, + hide_if_empty=True, + ) + + def get(self, request, pk, **kwargs): + actual_model = model_class + co_slug = kwargs.get('custom_object_type') + if co_slug and model_class._meta.app_label == APP_LABEL: + from netbox_custom_objects.models import CustomObjectType + cot = get_object_or_404(CustomObjectType, slug=co_slug) + actual_model = cot.get_model() + + try: + instance = get_object_or_404(actual_model.objects.restrict(request.user, 'view'), pk=pk) + except AttributeError: + instance = get_object_or_404(actual_model, pk=pk) + + linked_all = _get_linked_objects(instance) + + # Quick search filter + q = request.GET.get('q', '').strip() + if q: + q_lower = q.lower() + linked_all = [ + lo for lo in linked_all + if q_lower in str(lo.custom_object).lower() + or q_lower in str(lo.field.custom_object_type).lower() + or q_lower in str(lo.field).lower() + ] + + # Build table object for column-preference machinery + tab_table = CustomObjectsTabTable([], empty_text='') + visible_cols = None + if request.user.is_authenticated and (userconfig := getattr(request.user, 'config', None)): + visible_cols = userconfig.get(f'tables.{tab_table.name}.columns') + if visible_cols is None: + visible_cols = list(CustomObjectsTabTable.Meta.default_columns) + tab_table._set_columns(visible_cols) + selected_columns = {col for col, _ in tab_table.selected_columns} | set(tab_table.exempt_columns) + + # Pagination + paginator = EnhancedPaginator(linked_all, get_paginate_count(request)) + try: + page = paginator.page(int(request.GET.get('page', 1))) + except Exception: + page = paginator.page(1) + + # Resolve field values for current page only + page_rows = [ + (lo.custom_object, lo.field, _get_field_value(lo.custom_object, lo.field)) + for lo in page.object_list + ] + + return render(request, 'netbox_custom_objects/tabs/combined_tab.html', { + 'object': instance, + 'tab': self.tab, + 'base_template': _get_base_template(instance), + 'page_obj': page, + 'paginator': paginator, + 'page_rows': page_rows, + 'tab_table': tab_table, + 'selected_columns': selected_columns, + 'return_url': request.get_full_path(), + 'q': q, + }) + + CombinedTabView.__name__ = f'{model_class.__name__}CombinedTabView' + CombinedTabView.__qualname__ = CombinedTabView.__name__ + return CombinedTabView + + +def _register_combined_tabs(model_classes): + """Register combined tab on each model.""" + for model_class in model_classes: + app = model_class._meta.app_label + name = model_class._meta.model_name + if any(e['name'] == 'custom_objects' for e in registry['views'].get(app, {}).get(name, [])): + continue + register_model_view(model_class, name='custom_objects', path='custom-objects')( + _make_combined_tab_view(model_class) + ) + + +# =========================================================================== +# Typed tabs +# =========================================================================== + +def _build_typed_table_class(cot, dynamic_model): + """Build a django-tables2 table class for a COT.""" + model_fields = cot.fields.all() + fields = ['id'] + [f.name for f in model_fields if f.ui_visible != CustomFieldUIVisibleChoices.HIDDEN] + + meta = type('Meta', (), { + 'model': dynamic_model, + 'fields': fields, + 'attrs': {'class': 'table table-hover object-list'}, + }) + + attrs = {'Meta': meta, '__module__': 'database.tables'} + + for field in model_fields: + if field.ui_visible == CustomFieldUIVisibleChoices.HIDDEN: + continue + ft = field_types.FIELD_TYPE_CLASS[field.type]() + try: + attrs[field.name] = ft.get_table_column_field(field) + except NotImplementedError: + pass + linkable = [CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_LONGTEXT] + if field.primary and field.type in linkable: + attrs[f'render_{field.name}'] = ft.render_table_column_linkified + else: + try: + attrs[f'render_{field.name}'] = ft.render_table_column + except AttributeError: + pass + + return type(f'{dynamic_model._meta.object_name}Table', (CustomObjectTable,), attrs) + + +def _build_filterset_form(cot, dynamic_model): + """Build a filterset form class for a COT.""" + attrs = { + 'model': dynamic_model, + '__module__': 'database.filterset_forms', + 'tag': TagFilterField(dynamic_model), + } + for field in cot.fields.all(): + ft = field_types.FIELD_TYPE_CLASS[field.type]() + try: + attrs[field.name] = ft.get_filterform_field(field) + except NotImplementedError: + pass + return type(f'{dynamic_model._meta.object_name}FilterForm', (NetBoxModelFilterSetForm,), attrs) + + +def _count_for_type(cot, field_infos): + """Badge callable for one COT. Returns None when 0. Uses OR + distinct to avoid double-counting.""" + cot_pk = cot.pk + + def _badge(instance): + from netbox_custom_objects.models import CustomObjectType + try: + c = CustomObjectType.objects.get(pk=cot_pk) + model = c.get_model() + except Exception: + return None + q = Q() + for field_name, field_type in field_infos: + try: + if field_type == CustomFieldTypeChoices.TYPE_OBJECT: + q |= Q(**{f'{field_name}_id': instance.pk}) + elif field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + q |= Q(**{field_name: instance.pk}) + except Exception: + pass + if not q: + return None + total = model.objects.filter(q).distinct().count() + return total or None + + return _badge + + +def _make_typed_tab_view(model_class, cot, field_infos, weight): + """Factory: returns a View subclass for a per-COT typed tab.""" + badge_fn = _count_for_type(cot, field_infos) + cot_pk = cot.pk + cot_label = str(cot) + + class TypedTabView(View): + tab = ViewTab(label=cot_label, badge=badge_fn, weight=weight, hide_if_empty=True) + + def get(self, request, pk, **kwargs): + try: + instance = get_object_or_404(model_class.objects.restrict(request.user, 'view'), pk=pk) + except AttributeError: + instance = get_object_or_404(model_class, pk=pk) + + from netbox_custom_objects.models import CustomObjectType as COTModel + error_ctx = { + 'object': instance, 'tab': self.tab, + 'base_template': _get_base_template(instance), + 'table': None, 'preferences': {'pagination.placement': 'bottom'}, + } + try: + c = COTModel.objects.get(pk=cot_pk) + dynamic_model = c.get_model() + except Exception: + return render(request, 'netbox_custom_objects/tabs/typed_tab.html', error_ctx) + + q = Q() + for fn, ft in field_infos: + if ft == CustomFieldTypeChoices.TYPE_OBJECT: + q |= Q(**{f'{fn}_id': instance.pk}) + elif ft == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + q |= Q(**{fn: instance.pk}) + + base_qs = dynamic_model.objects.filter(q).distinct() + filterset = get_filterset_class(dynamic_model)(request.GET, queryset=base_qs) + filter_form = _build_filterset_form(c, dynamic_model)(request.GET) + + table = _build_typed_table_class(c, dynamic_model)(filterset.qs) + table.columns.show('pk') + table.htmx_url = request.path + table.embedded = False + table.configure(request) + + if request.user.is_authenticated and (uc := getattr(request.user, 'config', None)): + prefs = {'pagination.placement': uc.get('pagination.placement', 'bottom')} + else: + prefs = {'pagination.placement': 'bottom'} + + ctx = { + 'object': instance, 'tab': self.tab, + 'base_template': _get_base_template(instance), + 'table': table, 'filter_form': filter_form, + 'return_url': request.get_full_path(), + 'custom_object_type': c, 'model': dynamic_model, + 'preferences': prefs, + } + if request.htmx and not request.htmx.boosted: + return render(request, 'htmx/table.html', ctx) + return render(request, 'netbox_custom_objects/tabs/typed_tab.html', ctx) + + TypedTabView.__name__ = f'{model_class.__name__}_{cot.slug}_TypedTabView' + TypedTabView.__qualname__ = TypedTabView.__name__ + return TypedTabView + + +def _register_typed_tabs(model_classes, weight=2100): + """Register per-type tabs for COTs listed in typed_tab_slugs plugin config.""" + from netbox.plugins import get_plugin_config + typed_slugs = get_plugin_config('netbox_custom_objects', 'typed_tab_slugs') or [] + if not typed_slugs: + return + + try: + all_fields = CustomObjectTypeField.objects.filter( + type__in=[CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT], + custom_object_type__slug__in=typed_slugs, + ).select_related('custom_object_type') + + ct_cot_fields = defaultdict(list) + ct_cot_map = {} + for f in all_fields: + if f.related_object_type_id is None: + continue + key = (f.related_object_type_id, f.custom_object_type_id) + ct_cot_fields[key].append((f.name, f.type)) + ct_cot_map[key] = f.custom_object_type + + model_ct_map = {} + for mc in model_classes: + ct = ContentType.objects.get_for_model(mc) + model_ct_map[ct.pk] = mc + except (OperationalError, ProgrammingError): + logger.warning('database unavailable — typed tabs not registered') + return + + for (ct_id, cot_pk), field_infos in ct_cot_fields.items(): + if ct_id not in model_ct_map: + continue + mc = model_ct_map[ct_id] + cot = ct_cot_map[(ct_id, cot_pk)] + slug = cot.slug + existing = registry['views'].get(mc._meta.app_label, {}).get(mc._meta.model_name, []) + if any(e['name'] == f'custom_objects_{slug}' for e in existing): + continue + register_model_view(mc, name=f'custom_objects_{slug}', path=f'custom-objects-{slug}')( + _make_typed_tab_view(mc, cot, field_infos, weight) + ) + logger.info('registered typed tab "%s" for %s.%s', slug, mc._meta.app_label, mc._meta.model_name) + + +# =========================================================================== +# Orchestrator +# =========================================================================== + +def inject_co_urls(): + """Inject URL patterns for tab views on CO dynamic model detail pages.""" + try: + import netbox_custom_objects.urls as co_urls + from django.urls import path as url_path + except ImportError: + return + + co_views = {} + for model_name, entries in registry['views'].get(APP_LABEL, {}).items(): + if not model_name.startswith('table'): + continue + for e in entries: + if e['name'].startswith('custom_objects') and e['name'] not in co_views: + co_views[e['name']] = (e['path'], e['view']) + + existing = {p.name for p in co_urls.urlpatterns if hasattr(p, 'name') and p.name} + for action, (path_str, view_cls) in co_views.items(): + url_name = f'customobject_{action}' + if url_name in existing: + continue + co_urls.urlpatterns.append( + url_path(f'//{path_str}/', view_cls.as_view(), name=url_name) + ) + + +def deduplicate_registry(): + """Remove duplicate view registrations. Call AFTER super().ready().""" + for _app, model_map in registry['views'].items(): + for model_name, entries in model_map.items(): + seen = set() + deduped = [] + for e in entries: + if e['name'] not in seen: + seen.add(e['name']) + deduped.append(e) + if len(deduped) < len(entries): + model_map[model_name] = deduped + + +def _discover_referenced_models(): + """ + Discover models referenced by CO fields. + Uses app_config.get_models() for CO models — NEVER get_model(). + """ + from netbox_custom_objects.models import CustomObject + try: + app_config = apps.get_app_config(APP_LABEL) + except LookupError: + return [] + + co_models = [m for m in app_config.get_models() if issubclass(m, CustomObject) and m is not CustomObject] + + try: + ref_fields = CustomObjectTypeField.objects.filter( + type__in=[CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT], + ).select_related('related_object_type') + except (OperationalError, ProgrammingError): + return [] + + seen = set() + result = [] + for f in ref_fields: + if f.related_object_type_id is None: + continue + ct = f.related_object_type + key = (ct.app_label, ct.model) + if key in seen: + continue + seen.add(key) + if ct.app_label == APP_LABEL: + match = next((m for m in co_models if m._meta.model_name == ct.model), None) + if match: + result.append(match) + else: + try: + result.append(apps.get_model(ct.app_label, ct.model)) + except LookupError: + pass + + # Include CO models that might receive CO-to-CO tabs + for m in co_models: + if m not in result: + result.append(m) + + return result + + +def register_all_tabs(): + """ + Main entry point — called from ready(). + Registers combined + typed tabs. Must run BEFORE URL conf is loaded. + """ + models = _discover_referenced_models() + if not models: + return + + logger.info('register_all_tabs: %d models discovered', len(models)) + _register_combined_tabs(models) + _register_typed_tabs(models) diff --git a/netbox_custom_objects/templates/netbox_custom_objects/tabs/combined_tab.html b/netbox_custom_objects/templates/netbox_custom_objects/tabs/combined_tab.html new file mode 100644 index 00000000..4caa3225 --- /dev/null +++ b/netbox_custom_objects/templates/netbox_custom_objects/tabs/combined_tab.html @@ -0,0 +1,142 @@ +{% extends base_template %} +{% load i18n perms helpers %} +{% block content %} + {# Controls row — Quick search + Configure Table #} +
+
+
+ + + {% if q %} + + {% endif %} + +
+
+
+
+ +
+
+
+ {# Table in card #} +
+ {% if page_rows %} +
+ + + + {% if 'type' in selected_columns %} + + {% endif %} + {% if 'object' in selected_columns %} + + {% endif %} + {% if 'value' in selected_columns %} + + {% endif %} + {% if 'field' in selected_columns %} + + {% endif %} + {% if 'tags' in selected_columns %} + + {% endif %} + + + + + {% for obj, field, value in page_rows %} + + {% if 'type' in selected_columns %} + + {% endif %} + {% if 'object' in selected_columns %} + + {% endif %} + {% if 'value' in selected_columns %} + + {% endif %} + {% if 'field' in selected_columns %}{% endif %} + {% if 'tags' in selected_columns %} + + {% endif %} + + + {% endfor %} + +
{% trans "Type" %}{% trans "Object" %}{% trans "Value" %}{% trans "Field" %}{% trans "Tags" %}
+ {% if request.user|can_view:field.custom_object_type %} + {{ field.custom_object_type }} + {% else %} + {{ field.custom_object_type }} + {% endif %} + + {{ obj }} + + {% if field.type == 'object' %} + {% if value %} + {{ value }} + {% else %} + — + {% endif %} + {% elif field.type == 'multiobject' %} + {% if value %} + {% for related_obj in value|slice:":3" %} + {{ related_obj }} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% if value|length > 3 %}…{% endif %} + {% else %} + — + {% endif %} + {% else %} + — + {% endif %} + {{ field }} + {% for t in obj.tags.all %} + {% tag t %} + {% empty %} + — + {% endfor %} + + + {% if request.user|can_change:obj %} + + + + + Toggle Dropdown + + + {% endif %} + +
+
+ {% include 'inc/paginator.html' with paginator=paginator page=page_obj placement='bottom' %} + {% else %} +
+ {% trans "No custom objects are linked to this object." %} +
+ {% endif %} +
+{% endblock content %} +{% block modals %} + {{ block.super }} + {% table_config_form tab_table %} +{% endblock modals %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/tabs/typed_tab.html b/netbox_custom_objects/templates/netbox_custom_objects/tabs/typed_tab.html new file mode 100644 index 00000000..65751a87 --- /dev/null +++ b/netbox_custom_objects/templates/netbox_custom_objects/tabs/typed_tab.html @@ -0,0 +1,72 @@ +{% extends base_template %} +{% load helpers %} +{% load render_table from django_tables2 %} +{% load i18n %} +{% block content %} + {% if table %} + {# Results / Filters sub-tabs (standard NetBox list view pattern) #} + + {# Results tab pane #} +
+ {% if filter_form %} + {% applied_filters model filter_form request.GET %} + {% endif %} + {% include 'inc/table_controls_htmx.html' with table_modal=table.name|add:"_config" %} +
+ {% csrf_token %} + +
+
+ {% include 'htmx/table.html' %} +
+
+
+ + +
+
+
+ {# Filters tab pane #} + {% if filter_form %} +
+ {% include 'inc/filter_list.html' %} +
+ {% endif %} + {% else %} +
+
{% trans "No custom objects are linked to this object." %}
+
+ {% endif %} +{% endblock content %} +{% block modals %} + {% if table %} + {% table_config_form table %} + {% endif %} +{% endblock modals %} From 5e93f821121eab143a570ee0d62fb32ff4d3e07f Mon Sep 17 00:00:00 2001 From: Jan Krupa <> Date: Thu, 26 Mar 2026 09:50:36 +0000 Subject: [PATCH 2/6] Wire tab registration into plugin and fix pre-existing view bugs - Add typed_tab_slugs to default_settings in PluginConfig - Call register_all_tabs() in ready() before super().ready() - Call inject_co_urls() and deduplicate_registry() after super().ready() - Add model_view_tabs to customobject.html for CO-to-CO tab support - Fix JournalEntryTable and ObjectChangeTable: remove unsupported user kwarg - Fix journal/changelog views to pass self.tab instead of string literals --- netbox_custom_objects/__init__.py | 15 +++++++++++++++ .../netbox_custom_objects/customobject.html | 7 +------ netbox_custom_objects/views.py | 8 ++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 90af591e..0ebb54ae 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -81,6 +81,10 @@ class CustomObjectsPluginConfig(PluginConfig): default_settings = { # The maximum number of Custom Object Types that may be created 'max_custom_object_types': 50, + # List of COT slugs that get dedicated typed tabs on related object detail pages. + # Requires server restart after changes. + # Example: ['firewall-rules', 'security-audits'] + 'typed_tab_slugs': [], } required_settings = [] template_extensions = "template_content.template_extensions" @@ -204,8 +208,19 @@ def ready(self): super().ready() return + # Register related-object tabs (combined + typed) + from .tab_views import register_all_tabs + register_all_tabs() + super().ready() + # These must run AFTER super().ready() which registers journal/changelog views + # and triggers URL conf generation via register_models() + if not self.should_skip_dynamic_model_creation(): + from .tab_views import inject_co_urls, deduplicate_registry + inject_co_urls() + deduplicate_registry() + def get_model(self, model_name, require_ready=True): self.apps.check_apps_ready() try: diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html index 709dc324..a93898f7 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html @@ -73,12 +73,7 @@ - - + {% model_view_tabs object %} {% endblock tabs %} diff --git a/netbox_custom_objects/views.py b/netbox_custom_objects/views.py index af646a01..8de2f268 100644 --- a/netbox_custom_objects/views.py +++ b/netbox_custom_objects/views.py @@ -831,7 +831,7 @@ def get(self, request, custom_object_type, **kwargs): ) journal_table = JournalEntryTable( - data=journal_entries, orderable=False, user=request.user + data=journal_entries, orderable=False ) journal_table.configure(request) journal_table.columns.hide("assigned_object_type") @@ -861,7 +861,7 @@ def get(self, request, custom_object_type, **kwargs): "form": form, "table": journal_table, "base_template": self.base_template, - "tab": "journal", + "tab": self.tab, "form_action": reverse( "plugins:netbox_custom_objects:custom_journalentry_add" ), @@ -903,7 +903,7 @@ def get(self, request, custom_object_type, **kwargs): ) objectchanges_table = ObjectChangeTable( - data=objectchanges, orderable=False, user=request.user + data=objectchanges, orderable=False ) objectchanges_table.configure(request) @@ -918,6 +918,6 @@ def get(self, request, custom_object_type, **kwargs): "object": obj, "table": objectchanges_table, "base_template": self.base_template, - "tab": "changelog", + "tab": self.tab, }, ) From 237e76617d3173f51bda24c51953ad41043dbb47 Mon Sep 17 00:00:00 2001 From: Jan Krupa <> Date: Thu, 26 Mar 2026 09:50:48 +0000 Subject: [PATCH 3/6] Document related object tabs in README - Add Related Object Tabs section explaining combined and typed tabs - Document typed_tab_slugs PLUGINS_CONFIG setting with example - Note that restart is required after config changes --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 1a3cf5f0..247adc7c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,26 @@ PLUGINS_CONFIG = { } ``` +## Related Object Tabs + +When a Custom Object Type has fields referencing other NetBox objects (e.g., a "Firewall Rules" type with a Device field), a **Combined "Custom Objects" tab** automatically appears on the detail pages of those referenced objects, showing all linked custom objects. + +You can also enable **dedicated typed tabs** for specific Custom Object Types by adding their slugs to `PLUGINS_CONFIG`: + +```python +PLUGINS_CONFIG = { + 'netbox_custom_objects': { + 'typed_tab_slugs': [ + 'firewall-rules', + 'security-audits', + ], + }, +} +``` + +> [!NOTE] +> After adding or removing slugs from `typed_tab_slugs`, a NetBox restart is required for the changes to take effect. The combined tab is always active and requires no configuration. + ## Known Limitations NetBox Custom Objects is now Generally Available which means you can use it in production and migrations to future versions will work. There are many upcoming features including GraphQL support - the best place to see what's on the way is the [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) list on the GitHub repository. From 1b8ade5a502c21a9dc5bb18deef23b83e07c5543 Mon Sep 17 00:00:00 2001 From: Jan Krupa <> Date: Tue, 21 Apr 2026 10:48:33 +0000 Subject: [PATCH 4/6] Simplify tab_views: reuse shared helpers, narrow exception handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace _build_filterset_form with shared dynamic_forms.build_filterset_form_class (per prior PR #445 feedback / commit 26a39a5 invariant) - Extract _build_link_q helper; removes copy-pasted Q loop from _count_for_type._badge and TypedTabView.get - Drop CustomObjectType.objects.get(pk=cot_pk) refetch from typed-tab badge; use captured cot directly - Narrow bare except Exception to (OperationalError, ProgrammingError) in _count_linked and _get_linked_objects - prefetch_related('tags') in _get_linked_objects — kills per-row tag N+1 - Switch .restrict() try/except AttributeError to hasattr() guard; matches NetBox core pattern in netbox/views/generic/feature_views.py - Fix stale module docstring: typed-tab opt-in is typed_tab_slugs in PLUGINS_CONFIG, not show_tab=True --- netbox_custom_objects/tab_views.py | 76 +++++++++++------------------- 1 file changed, 27 insertions(+), 49 deletions(-) diff --git a/netbox_custom_objects/tab_views.py b/netbox_custom_objects/tab_views.py index dcec61ec..ebda72cd 100644 --- a/netbox_custom_objects/tab_views.py +++ b/netbox_custom_objects/tab_views.py @@ -3,7 +3,8 @@ Two tab types: 1. Combined "Custom Objects" tab — shows all linked custom objects in a simple table. -2. Per-COT typed tabs — opt-in via show_tab=True, with type-specific columns, filters, bulk actions. +2. Per-COT typed tabs — opt-in via the ``typed_tab_slugs`` list in ``PLUGINS_CONFIG``, + with type-specific columns, filters, and bulk actions. CRITICAL: During registration, never call get_model() or apps.get_model() for dynamic CO models. Read from app_config.get_models() instead, as each get_model() cache miss re-registers @@ -24,10 +25,8 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import View from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices -from netbox.forms import NetBoxModelFilterSetForm from netbox.registry import registry from netbox.tables import BaseTable -from utilities.forms.fields import TagFilterField from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.views import ViewTab, register_model_view @@ -35,6 +34,7 @@ from netbox_custom_objects import field_types from netbox_custom_objects.constants import APP_LABEL +from netbox_custom_objects.dynamic_forms import build_filterset_form_class from netbox_custom_objects.filtersets import get_filterset_class from netbox_custom_objects.models import CustomObjectTypeField from netbox_custom_objects.tables import CustomObjectTable @@ -128,7 +128,7 @@ def _count_linked(instance): for _field, model, fk in _iter_linked_fields(instance): try: total += model.objects.filter(**fk).count() - except Exception: + except (OperationalError, ProgrammingError): pass return total or None @@ -138,9 +138,9 @@ def _get_linked_objects(instance): results = [] for field, model, fk in _iter_linked_fields(instance): try: - for obj in model.objects.filter(**fk): + for obj in model.objects.filter(**fk).prefetch_related('tags'): results.append(LinkedCustomObject(custom_object=obj, field=field)) - except Exception: + except (OperationalError, ProgrammingError): pass return results @@ -164,10 +164,10 @@ def get(self, request, pk, **kwargs): cot = get_object_or_404(CustomObjectType, slug=co_slug) actual_model = cot.get_model() - try: - instance = get_object_or_404(actual_model.objects.restrict(request.user, 'view'), pk=pk) - except AttributeError: - instance = get_object_or_404(actual_model, pk=pk) + qs = actual_model.objects + if hasattr(qs, 'restrict'): + qs = qs.restrict(request.user, 'view') + instance = get_object_or_404(qs, pk=pk) linked_all = _get_linked_objects(instance) @@ -272,42 +272,26 @@ def _build_typed_table_class(cot, dynamic_model): return type(f'{dynamic_model._meta.object_name}Table', (CustomObjectTable,), attrs) -def _build_filterset_form(cot, dynamic_model): - """Build a filterset form class for a COT.""" - attrs = { - 'model': dynamic_model, - '__module__': 'database.filterset_forms', - 'tag': TagFilterField(dynamic_model), - } - for field in cot.fields.all(): - ft = field_types.FIELD_TYPE_CLASS[field.type]() - try: - attrs[field.name] = ft.get_filterform_field(field) - except NotImplementedError: - pass - return type(f'{dynamic_model._meta.object_name}FilterForm', (NetBoxModelFilterSetForm,), attrs) +def _build_link_q(field_infos, instance_pk): + """Build the OR'd Q filter selecting CO rows that link to *instance_pk* via any of *field_infos*.""" + q = Q() + for field_name, field_type in field_infos: + if field_type == CustomFieldTypeChoices.TYPE_OBJECT: + q |= Q(**{f'{field_name}_id': instance_pk}) + elif field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + q |= Q(**{field_name: instance_pk}) + return q def _count_for_type(cot, field_infos): """Badge callable for one COT. Returns None when 0. Uses OR + distinct to avoid double-counting.""" - cot_pk = cot.pk def _badge(instance): - from netbox_custom_objects.models import CustomObjectType try: - c = CustomObjectType.objects.get(pk=cot_pk) - model = c.get_model() + model = cot.get_model() except Exception: return None - q = Q() - for field_name, field_type in field_infos: - try: - if field_type == CustomFieldTypeChoices.TYPE_OBJECT: - q |= Q(**{f'{field_name}_id': instance.pk}) - elif field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - q |= Q(**{field_name: instance.pk}) - except Exception: - pass + q = _build_link_q(field_infos, instance.pk) if not q: return None total = model.objects.filter(q).distinct().count() @@ -326,10 +310,10 @@ class TypedTabView(View): tab = ViewTab(label=cot_label, badge=badge_fn, weight=weight, hide_if_empty=True) def get(self, request, pk, **kwargs): - try: - instance = get_object_or_404(model_class.objects.restrict(request.user, 'view'), pk=pk) - except AttributeError: - instance = get_object_or_404(model_class, pk=pk) + qs = model_class.objects + if hasattr(qs, 'restrict'): + qs = qs.restrict(request.user, 'view') + instance = get_object_or_404(qs, pk=pk) from netbox_custom_objects.models import CustomObjectType as COTModel error_ctx = { @@ -343,16 +327,10 @@ def get(self, request, pk, **kwargs): except Exception: return render(request, 'netbox_custom_objects/tabs/typed_tab.html', error_ctx) - q = Q() - for fn, ft in field_infos: - if ft == CustomFieldTypeChoices.TYPE_OBJECT: - q |= Q(**{f'{fn}_id': instance.pk}) - elif ft == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - q |= Q(**{fn: instance.pk}) - + q = _build_link_q(field_infos, instance.pk) base_qs = dynamic_model.objects.filter(q).distinct() filterset = get_filterset_class(dynamic_model)(request.GET, queryset=base_qs) - filter_form = _build_filterset_form(c, dynamic_model)(request.GET) + filter_form = build_filterset_form_class(dynamic_model)(request.GET) table = _build_typed_table_class(c, dynamic_model)(filterset.qs) table.columns.show('pk') From bff33e30dd1b0e7cd07c4959e795bba15a2e5aa8 Mon Sep 17 00:00:00 2001 From: Jan Krupa <> Date: Tue, 21 Apr 2026 11:42:20 +0000 Subject: [PATCH 5/6] Restrict linked Custom Object querysets by view permission Apply .restrict(user, 'view') to linked-CO querysets so users without view permission on a referenced Custom Object model don't see its rows rendered in the related-object tab bodies. - _get_linked_objects now takes a user and restricts each per-model qs before filtering; the combined-tab view passes request.user. - TypedTabView.get restricts dynamic_model.objects before the link-Q filter so the typed-tab body is also gated. The combined-tab badge count can still include rows the user can't see; the body render itself is now restricted. --- netbox_custom_objects/tab_views.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/netbox_custom_objects/tab_views.py b/netbox_custom_objects/tab_views.py index ebda72cd..8290fdee 100644 --- a/netbox_custom_objects/tab_views.py +++ b/netbox_custom_objects/tab_views.py @@ -133,12 +133,15 @@ def _count_linked(instance): return total or None -def _get_linked_objects(instance): - """Return list of LinkedCustomObject for all COs referencing *instance*.""" +def _get_linked_objects(instance, user): + """Return list of LinkedCustomObject for all COs referencing *instance*, filtered by *user* permissions.""" results = [] for field, model, fk in _iter_linked_fields(instance): + qs = model.objects + if hasattr(qs, 'restrict'): + qs = qs.restrict(user, 'view') try: - for obj in model.objects.filter(**fk).prefetch_related('tags'): + for obj in qs.filter(**fk).prefetch_related('tags'): results.append(LinkedCustomObject(custom_object=obj, field=field)) except (OperationalError, ProgrammingError): pass @@ -169,7 +172,7 @@ def get(self, request, pk, **kwargs): qs = qs.restrict(request.user, 'view') instance = get_object_or_404(qs, pk=pk) - linked_all = _get_linked_objects(instance) + linked_all = _get_linked_objects(instance, request.user) # Quick search filter q = request.GET.get('q', '').strip() @@ -328,7 +331,10 @@ def get(self, request, pk, **kwargs): return render(request, 'netbox_custom_objects/tabs/typed_tab.html', error_ctx) q = _build_link_q(field_infos, instance.pk) - base_qs = dynamic_model.objects.filter(q).distinct() + base_qs = dynamic_model.objects + if hasattr(base_qs, 'restrict'): + base_qs = base_qs.restrict(request.user, 'view') + base_qs = base_qs.filter(q).distinct() filterset = get_filterset_class(dynamic_model)(request.GET, queryset=base_qs) filter_form = build_filterset_form_class(dynamic_model)(request.GET) From fda8748b910a58bc2faf22e10d574a004183b14e Mon Sep 17 00:00:00 2001 From: Jan Krupa <> Date: Tue, 21 Apr 2026 11:42:34 +0000 Subject: [PATCH 6/6] Hide typed tabs when the user lacks view permission on the CO model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass a permission string into ViewTab so NetBox's core tab template tag skips the typed tab entirely for users without .view_ permission on the underlying Custom Object model. Previously the badge count could include restricted rows while the body correctly hid them, producing a badge-vs-empty-body mismatch. The permission string is derived from the CO model resolved via model_ct_map at registration time (cot.object_type_id -> model), so no cot.get_model() call is introduced during tab registration. The combined tab is left unguarded intentionally — it aggregates across all CO types and is filtered per-row by the restrict() fix already in place. --- netbox_custom_objects/tab_views.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox_custom_objects/tab_views.py b/netbox_custom_objects/tab_views.py index 8290fdee..8b16b647 100644 --- a/netbox_custom_objects/tab_views.py +++ b/netbox_custom_objects/tab_views.py @@ -303,14 +303,20 @@ def _badge(instance): return _badge -def _make_typed_tab_view(model_class, cot, field_infos, weight): +def _make_typed_tab_view(model_class, cot, field_infos, weight, permission=None): """Factory: returns a View subclass for a per-COT typed tab.""" badge_fn = _count_for_type(cot, field_infos) cot_pk = cot.pk cot_label = str(cot) class TypedTabView(View): - tab = ViewTab(label=cot_label, badge=badge_fn, weight=weight, hide_if_empty=True) + tab = ViewTab( + label=cot_label, + badge=badge_fn, + weight=weight, + permission=permission, + hide_if_empty=True, + ) def get(self, request, pk, **kwargs): qs = model_class.objects @@ -405,8 +411,14 @@ def _register_typed_tabs(model_classes, weight=2100): existing = registry['views'].get(mc._meta.app_label, {}).get(mc._meta.model_name, []) if any(e['name'] == f'custom_objects_{slug}' for e in existing): continue + co_model = model_ct_map.get(cot.object_type_id) + permission = ( + f'{co_model._meta.app_label}.view_{co_model._meta.model_name}' + if co_model is not None + else None + ) register_model_view(mc, name=f'custom_objects_{slug}', path=f'custom-objects-{slug}')( - _make_typed_tab_view(mc, cot, field_infos, weight) + _make_typed_tab_view(mc, cot, field_infos, weight, permission=permission) ) logger.info('registered typed tab "%s" for %s.%s', slug, mc._meta.app_label, mc._meta.model_name)