diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc8d15..574a428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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.1] - 2026-09-14 + +### Fixed + +- **`AttributeError: 'str' object has no attribute 'label'` on Custom Object + Journal / Changelog / Contacts / Config Context pages** (#19). Regression + from 2.6.0: the `(label, weight)` active-tab fallback in `plugin_extra_tabs` + assumed `context["tab"]` is always a `ViewTab`, but `netbox-custom-objects`' + journal/changelog/contacts/configcontext views set it to a plain string. + The tag now reads `label`/`weight` with `getattr`, so a string marker simply + yields "not active". Triggered on any CO detail page that has at least one + combined/typed tab registered (`netbox_custom_objects.*` in + `combined_models`/`typed_models`). +- **Duplicate, never-active "Contacts" tab on Custom Object detail pages.** + NetBox (≥ 4.3) auto-registers an `ObjectContactsView` tab for every + `ContactsMixin` model, including Custom Object models; `plugin_extra_tabs` + rendered it next to the hardcoded Contacts tab. It is now excluded like + Journal/Changelog. + ## [2.6.0] - 2026-09-04 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 16ab8c3..3d4874c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,6 +217,11 @@ permissions internally via `get_permission_for_model()`. - `netbox_custom_objects` uses a single generic URL view (`CustomObjectView`) for all CO detail pages — it never calls `get_model_urls()` for dynamic models. `_inject_co_urls()` appends our tab URL patterns to `netbox_custom_objects.urls.urlpatterns` at `ready()` time (safe: Django loads URL conf lazily on first request) - `SavedFiltersMixin` lives at `netbox.forms.mixins`, not `extras.forms.mixins` - **Journal/Changelog tabs on CO pages must be hardcoded `
  • ` blocks, not rendered via `{% model_view_tabs %}`.** Upstream's `CustomObjectJournalView`/`CustomObjectChangeLogView` (`views.py:1321, 1393`) set `tab="journal"`/`"changelog"` as a **string** in the template context, while `model_view_tabs` (`utilities/templatetags/tabs.py:53`) computes `is_active = active_tab == tab` where `tab` is a `ViewTab` object — comparison always False → no `active` class. NetBox auto-registers Journal/Changelog views for ChangeLoggedModel subclasses in `netbox/models/features.py:737-742`, so `model_view_tabs` *will* render them, just never as active. Workaround: hardcode `
  • ` for Journal/Changelog (string comparison works), and use our `{% plugin_extra_tabs %}` tag (`templatetags/custom_object_tab_tags.py`) instead of `model_view_tabs` to render combined/typed tabs while filtering out journal/changelog (otherwise duplicate inert tabs appear). Fix landed for issue #15. +- **`context["tab"]` on CO detail pages may be a plain `str`** (`"journal"`, `"changelog"`, + `"contacts"`, `"configcontext"` — set by upstream's hand-rolled views). `plugin_extra_tabs` must + read `label`/`weight` off it with `getattr`, never attribute access (issue #19, regression in 2.6.0). + NetBox (≥ 4.3) also auto-registers a `contacts` ViewTab (`netbox/models/features.py`, next to + journal/changelog), so `contacts` is in `_HARDCODED_TAB_NAMES` too — otherwise a duplicate tab renders. ## Critical Reference Files 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 0c38ff0..4707c3f 100644 --- a/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py +++ b/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py @@ -8,14 +8,14 @@ register = template.Library() -# NetBox's `extras` framework auto-registers ObjectJournalView/ObjectChangeLogView +# NetBox auto-registers ObjectContactsView/ObjectJournalView/ObjectChangeLogView # for every model that supports them (see netbox/models/features.py). On Custom -# Object detail pages we render those two tabs as hardcoded
  • blocks instead, -# because upstream's CustomObjectJournalView/CustomObjectChangeLogView put the -# string "journal"/"changelog" in the template context as the active-tab marker, -# which `model_view_tabs` cannot match against its ViewTab object. Filtering them -# out here prevents duplicate, never-active tabs from being rendered. -_HARDCODED_TAB_NAMES = frozenset({"journal", "changelog"}) +# Object detail pages we render those tabs as hardcoded
  • blocks instead, +# because upstream's CustomObjectContactsView/JournalView/ChangeLogView put the +# string "contacts"/"journal"/"changelog" in the template context as the +# active-tab marker, which `model_view_tabs` cannot match against its ViewTab +# object. Filtering them out here prevents duplicate, never-active tabs. +_HARDCODED_TAB_NAMES = frozenset({"contacts", "journal", "changelog"}) @register.inclusion_tag("tabs/model_view_tabs.html", takes_context=True) @@ -34,6 +34,11 @@ def plugin_extra_tabs(context, instance): except KeyError: views = [] + active_tab = context.get("tab") + # Upstream's CO journal/changelog/contacts/configcontext views put a plain str in + # context["tab"]; only a ViewTab has label/weight (issue #19). + active_key = (getattr(active_tab, "label", None), getattr(active_tab, "weight", None)) + for config in views: if config["name"] in _HARDCODED_TAB_NAMES: continue @@ -42,7 +47,6 @@ def plugin_extra_tabs(context, instance): if tab.permission and not user.has_perm(tab.permission): continue if attrs := tab.render(instance): - active_tab = context.get("tab") try: url = get_action_url(instance, action=config["name"], kwargs={"pk": instance.pk}) except NoReverseMatch: @@ -58,8 +62,7 @@ def plugin_extra_tabs(context, instance): # 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)), + "is_active": active_tab == tab or active_key == (tab.label, tab.weight), } ) diff --git a/pyproject.toml b/pyproject.toml index 16412d8..e0123fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-custom-objects-tab" -version = "2.6.0" +version = "2.6.1" description = "NetBox plugin that adds a Custom Objects tab to object detail pages" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index a0997c1..621e345 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -5,6 +5,8 @@ from types import SimpleNamespace from unittest.mock import patch +import pytest + def _tab(label, weight): return SimpleNamespace( @@ -12,14 +14,14 @@ def _tab(label, weight): ) -def _render(active_tab, registered_tab): +def _render(active_tab, registered_tab, name="custom_objects"): 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}]}}} + registry = {"views": {"netbox_custom_objects": {"table149model": [{"name": name, "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"] @@ -41,3 +43,15 @@ def test_different_label_is_not_active(): def test_no_active_tab_in_context(): assert _render(None, _tab("Custom Objects", 2000))[0]["is_active"] is False + + +@pytest.mark.parametrize("marker", ["journal", "changelog", "contacts", "configcontext"]) +def test_string_active_tab_marker_is_not_active(marker): + # Upstream CO journal/changelog/contacts/configcontext views set context["tab"] to a str (#19). + assert _render(marker, _tab("Custom Objects", 2000))[0]["is_active"] is False + + +@pytest.mark.parametrize("name", ["contacts", "journal", "changelog"]) +def test_hardcoded_tab_names_are_skipped(name): + # NetBox auto-registers these; the CO template renders them by hand, so the tag must not duplicate them. + assert _render(None, _tab("Contacts", 5000), name=name) == []