Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) |
Expand Down
16 changes: 15 additions & 1 deletion netbox_custom_objects_tab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down Expand Up @@ -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"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
43 changes: 43 additions & 0 deletions tests/test_templatetags.py
Original file line number Diff line number Diff line change
@@ -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
Loading