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) == []