From bebea708964adfaa03a4c544b5db67dc0956226d Mon Sep 17 00:00:00 2001 From: Jan Krupa Date: Fri, 4 Sep 2026 11:38:20 +0200 Subject: [PATCH 1/2] Add NetBox 4.7.x support (widen compat range to 4.5.2 - 4.7.99) - max_version 4.7.99. All NetBox APIs and templates the plugin uses are unchanged in 4.7; verified against a local 4.7.0 instance. - Raise ImproperlyConfigured at startup when netbox_custom_objects is not loaded (NetBox 4.7 skips netbox-custom-objects 0.6.0, whose max_version is 4.6.99), instead of an opaque 'isn't in INSTALLED_APPS' RuntimeError. - Fix combined/typed tab never rendered active on Custom Object detail pages for all but the first-registered CO type: the generic CO-page URL is bound to the first model's view class, so its ViewTab instance never equals the registry entry for the page's model. plugin_extra_tabs now falls back to (label, weight) equality. --- CLAUDE.md | 2 +- netbox_custom_objects_tab/__init__.py | 16 ++++++- .../templatetags/custom_object_tab_tags.py | 7 ++- tests/conftest.py | 1 + tests/test_templatetags.py | 43 +++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/test_templatetags.py diff --git a/CLAUDE.md b/CLAUDE.md index ded93e9..16ab8c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ To find all custom objects referencing a Device (pk=42): Reference: `netbox_custom_objects/template_content.py::CustomObjectLink.left_page()` -## Key Import Paths (NetBox 4.5.x / 4.6.x) +## Key Import Paths (NetBox 4.5.x / 4.6.x / 4.7.x) ```python from utilities.views import ViewTab, register_model_view diff --git a/netbox_custom_objects_tab/__init__.py b/netbox_custom_objects_tab/__init__.py index 28d7dad..dac5ee6 100644 --- a/netbox_custom_objects_tab/__init__.py +++ b/netbox_custom_objects_tab/__init__.py @@ -12,7 +12,7 @@ class NetBoxCustomObjectsTabConfig(PluginConfig): author_email = "jan.krupa@cesnet.cz" base_url = "custom-objects-tab" min_version = "4.5.2" - max_version = "4.6.99" + max_version = "4.7.99" default_settings = { # Per-type tabs: each Custom Object Type gets its own tab (opt-in, empty by default). "typed_models": [], @@ -44,7 +44,21 @@ def ready(self): # Raising ImproperlyConfigured here aborts NetBox startup with a clean, # named error in the logs — preferable to letting a half-loaded plugin # NoReverseMatch mid-request. + from django.apps import apps from django.core.exceptions import ImproperlyConfigured + + # netbox_custom_objects must actually be loaded, not merely installed. + # NetBox skips a plugin whose max_version is below the running release + # (netbox-custom-objects 0.6.0 caps at 4.6.99, so NetBox 4.7 drops it + # with only a warning); importing its models then fails with an opaque + # "isn't in INSTALLED_APPS" RuntimeError at startup. + if not apps.is_installed("netbox_custom_objects"): + raise ImproperlyConfigured( + "netbox-custom-objects-tab requires the netbox_custom_objects plugin to be loaded. " + "On NetBox 4.7+ that needs netbox-custom-objects>=0.6.1 (0.6.0 declares max_version 4.6.99 " + "and is skipped by NetBox). Upgrade with: pip install -U 'netbox-custom-objects>=0.6.1'" + ) + from netbox_custom_objects.choices import CustomObjectFieldTypeChoices if not hasattr(CustomObjectFieldTypeChoices, "TYPE_COORDINATES"): diff --git a/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py b/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py index d718521..0c38ff0 100644 --- a/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py +++ b/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py @@ -54,7 +54,12 @@ def plugin_extra_tabs(context, instance): "label": attrs["label"], "badge": attrs["badge"], "weight": attrs["weight"], - "is_active": active_tab and active_tab == tab, + # Identity check first; fall back to (label, weight) because the + # generic CO-page URL (see views._inject_co_urls) is bound to the + # first model's view class, whose ViewTab instance differs from the + # registry entry for the page's actual model. + "is_active": bool(active_tab) + and (active_tab == tab or (active_tab.label, active_tab.weight) == (tab.label, tab.weight)), } ) diff --git a/tests/conftest.py b/tests/conftest.py index 67f1e03..a8d5aa5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -80,6 +80,7 @@ class _CustomFieldUIVisibleChoices: "utilities.views", ViewTab=MagicMock(), register_model_view=MagicMock(), + get_action_url=MagicMock(return_value="/x/"), get_default_template=MagicMock(side_effect=lambda model: f"{model._meta.app_label}/{model._meta.model_name}.html"), ) _mock("utilities.paginator", EnhancedPaginator=MagicMock(), get_paginate_count=MagicMock()) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py new file mode 100644 index 0000000..a0997c1 --- /dev/null +++ b/tests/test_templatetags.py @@ -0,0 +1,43 @@ +""" +Unit tests for the plugin_extra_tabs template tag. +""" + +from types import SimpleNamespace +from unittest.mock import patch + + +def _tab(label, weight): + return SimpleNamespace( + label=label, weight=weight, permission=None, render=lambda obj: {"label": label, "badge": 3, "weight": weight} + ) + + +def _render(active_tab, registered_tab): + from netbox_custom_objects_tab.templatetags import custom_object_tab_tags as tags + + instance = SimpleNamespace( + _meta=SimpleNamespace(app_label="netbox_custom_objects", model_name="table149model"), pk=1 + ) + view = SimpleNamespace(tab=registered_tab) + registry = {"views": {"netbox_custom_objects": {"table149model": [{"name": "custom_objects", "view": view}]}}} + context = {"request": SimpleNamespace(user=SimpleNamespace(has_perm=lambda p: True)), "tab": active_tab} + with patch.object(tags, "registry", registry), patch.object(tags, "get_action_url", return_value="/x/"): + return tags.plugin_extra_tabs(context, instance)["tabs"] + + +def test_same_instance_is_active(): + tab = _tab("Custom Objects", 2000) + assert _render(tab, tab)[0]["is_active"] is True + + +def test_equal_label_and_weight_but_different_instance_is_active(): + # Generic CO-page URL serves another model's view class -> different ViewTab object. + assert _render(_tab("Custom Objects", 2000), _tab("Custom Objects", 2000))[0]["is_active"] is True + + +def test_different_label_is_not_active(): + assert _render(_tab("Service", 2100), _tab("Custom Objects", 2000))[0]["is_active"] is False + + +def test_no_active_tab_in_context(): + assert _render(None, _tab("Custom Objects", 2000))[0]["is_active"] is False From 4f2e739a576d6547cbc494e8f53e055c9948d91b Mon Sep 17 00:00:00 2001 From: Jan Krupa Date: Fri, 4 Sep 2026 11:38:43 +0200 Subject: [PATCH 2/2] Release 2.6.0 --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ README.md | 10 ++++++---- pyproject.toml | 2 +- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bdf1fd..bcc8d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.0] - 2026-09-04 + +### Changed + +- **NetBox 4.7.x support** — `max_version` raised to 4.7.99 (supported range + is now 4.5.2 – 4.7.99). No plugin code needed to change: every NetBox API + and template this plugin uses (`ViewTab`, `register_model_view`, + `get_default_template`, `EnhancedPaginator`, `htmx_partial`, + `model_view_tabs`, `htmx/table.html`, `generic/object_list.html`) is + unchanged in 4.7, and none of the 4.7 removals (`{% querystring %}` with a + `request` argument, `registry['models']`, dict-style view `actions`, MPTT + columns) are used here. Verified against a local NetBox 4.7.0 instance + (combined and typed tabs on native and Custom Object pages, HTMX partials, + search, pagination, sorting). +- **On NetBox 4.7 you need `netbox-custom-objects` ≥ 0.6.1** + ([release notes](https://github.com/netboxlabs/netbox-custom-objects/releases/tag/v0.6.1)). + 0.6.0 declares `max_version` 4.6.99, so NetBox 4.7 skips it with a warning + and every plugin that depends on it fails. 4.5/4.6 installs can stay on 0.6.0. + +### Added + +- **Startup guard when `netbox_custom_objects` is not loaded.** + `PluginConfig.ready()` now raises `ImproperlyConfigured` with an actionable + message (`pip install -U 'netbox-custom-objects>=0.6.1'`) when the upstream + plugin is installed but was skipped by NetBox. Previously this surfaced as + an opaque `RuntimeError: Model class netbox_custom_objects.models.CustomObjectType + doesn't declare an explicit app_label and isn't in an application in + INSTALLED_APPS` during startup. + +### Fixed + +- **Combined/typed tab never highlighted as active on Custom Object detail + pages** for every Custom Object Type except the first one registered. + The generic CO-page URL injected by `_inject_co_urls()` is bound to the + first model's view class, so the `ViewTab` instance it puts in the template + context never equals the registry entry for the page's own model and + `plugin_extra_tabs` rendered the tab inactive. The tag now falls back to + `(label, weight)` equality when the identity check fails. Native-model + pages (Device, Site, …) were not affected. + ## [2.5.0] - 2026-08-24 ### Changed diff --git a/README.md b/README.md index 97518e0..49ca8b4 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ [![CI](https://github.com/CESNET/netbox-custom-objects-tab/actions/workflows/ci.yml/badge.svg)](https://github.com/CESNET/netbox-custom-objects-tab/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/netbox-custom-objects-tab)](https://pypi.org/project/netbox-custom-objects-tab/) [![Python](https://img.shields.io/pypi/pyversions/netbox-custom-objects-tab)](https://pypi.org/project/netbox-custom-objects-tab/) -[![NetBox](https://img.shields.io/badge/NetBox-4.5.x_|_4.6.x-blue)](https://github.com/netbox-community/netbox) +[![NetBox](https://img.shields.io/badge/NetBox-4.5.x_|_4.6.x_|_4.7.x-blue)](https://github.com/netbox-community/netbox) [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) -A NetBox 4.5.x / 4.6.x plugin that adds **Custom Objects** tabs to object detail pages, +A NetBox 4.5.x / 4.6.x / 4.7.x plugin that adds **Custom Objects** tabs to object detail pages, showing Custom Object instances from the `netbox_custom_objects` plugin that reference those objects via OBJECT or MULTIOBJECT fields. Works on standard NetBox models (Device, Site, Rack, …), third-party plugin models, and Custom Object detail pages themselves @@ -26,14 +26,16 @@ Two tab modes are available: ## Requirements -- NetBox 4.5.2 – 4.6.99 +- NetBox 4.5.2 – 4.7.99 - `netbox_custom_objects` plugin **≥ 0.6.0** installed and configured - (0.5.x installs must stay on plugin 2.4.1) + (**≥ 0.6.1 on NetBox 4.7** — 0.6.0 declares `max_version` 4.6.99 and is skipped by NetBox 4.7; + 0.5.x installs must stay on plugin 2.4.1) ## Compatibility | Plugin version | NetBox version | `netbox_custom_objects` version | |----------------|----------------|------------------------------------------------------------------------| +| 2.6.x | 4.5.2+ / 4.6.x / 4.7.x | **≥ 0.6.0 required** (≥ 0.6.1 on NetBox 4.7) | | 2.5.x | 4.5.2+ / 4.6.x | **≥ 0.6.0 required** | | 2.4.x | 4.5.2+ / 4.6.x | **≥ 0.5.1 required** | | 2.3.x | 4.5.4+ / 4.6.x | ≥ 0.4.6 (≥ 0.5.0 on 4.6) | diff --git a/pyproject.toml b/pyproject.toml index 7bff66c..16412d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-custom-objects-tab" -version = "2.5.0" +version = "2.6.0" description = "NetBox plugin that adds a Custom Objects tab to object detail pages" readme = "README.md" requires-python = ">=3.12"