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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<li>` 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 `<li>` 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

Expand Down
23 changes: 13 additions & 10 deletions netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <li> 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 <li> 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)
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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),
}
)

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.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"
Expand Down
18 changes: 16 additions & 2 deletions tests/test_templatetags.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@
from types import SimpleNamespace
from unittest.mock import patch

import pytest


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):
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"]
Expand All @@ -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) == []
Loading