From d66cc0261941d2bdf84b30bd1444c0dc02f351a3 Mon Sep 17 00:00:00 2001 From: Jan Krupa Date: Mon, 24 Aug 2026 09:08:51 +0200 Subject: [PATCH 1/4] Regenerate customobject.html from upstream 0.6.0; raise floor to netbox-custom-objects 0.6.0 The template override was based on 0.5.x and silently dropped upstream 0.6.0 features on the CO detail page: Contacts tab, Config Context tab, owner display, coordinates rendering with Map button, and the polymorphic M2M Type column. Regenerated from 0.6.0 stock with only our two deltas (custom_object_tab_tags load + {% plugin_extra_tabs %} between Contacts and Journal); the file keeps upstream formatting so future refreshes are a plain diff against new stock. The 0.6.0-based template reverses URLs that don't exist on 0.5.x, so the ready() probe now checks CustomObjectFieldTypeChoices.TYPE_COORDINATES (added in 0.6.0) and points users at netbox-custom-objects>=0.6.0. --- netbox_custom_objects_tab/__init__.py | 27 +- .../netbox_custom_objects/customobject.html | 323 ++++++++++-------- 2 files changed, 196 insertions(+), 154 deletions(-) diff --git a/netbox_custom_objects_tab/__init__.py b/netbox_custom_objects_tab/__init__.py index 1b0b966..28d7dad 100644 --- a/netbox_custom_objects_tab/__init__.py +++ b/netbox_custom_objects_tab/__init__.py @@ -34,23 +34,24 @@ class NetBoxCustomObjectsTabConfig(PluginConfig): def ready(self): super().ready() - # Hard gate: require netbox-custom-objects >= 0.5.0. We probe behaviour - # (the `is_polymorphic` model field added in 0.5.0) rather than parsing - # a version string, because forks and pre-release tags can carry any - # version label but either have or lack the field we actually use. + # Hard gate: require netbox-custom-objects >= 0.6.0. We probe behaviour + # (the `coordinates` field type added in 0.6.0) rather than parsing a + # version string, because forks and pre-release tags can carry any + # version label but either have or lack the feature we actually use. + # Our customobject.html override is a copy of the 0.6.0 stock template + # (Contacts/Config Context tabs, coordinates rendering, owner header), + # which reverses URLs that don't exist on 0.5.x — hence the hard floor. # Raising ImproperlyConfigured here aborts NetBox startup with a clean, # named error in the logs — preferable to letting a half-loaded plugin - # ImportError mid-request. - from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured - from netbox_custom_objects.models import CustomObjectTypeField + # NoReverseMatch mid-request. + from django.core.exceptions import ImproperlyConfigured + from netbox_custom_objects.choices import CustomObjectFieldTypeChoices - try: - CustomObjectTypeField._meta.get_field("is_polymorphic") - except FieldDoesNotExist as exc: + if not hasattr(CustomObjectFieldTypeChoices, "TYPE_COORDINATES"): raise ImproperlyConfigured( - "netbox-custom-objects-tab 2.4+ requires netbox-custom-objects>=0.5.1. " - "Upgrade with: pip install -U 'netbox-custom-objects>=0.5.1'" - ) from exc + "netbox-custom-objects-tab 2.5+ requires netbox-custom-objects>=0.6.0. " + "Upgrade with: pip install -U 'netbox-custom-objects>=0.6.0'" + ) from . import template_override, views diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects_tab/templates/netbox_custom_objects/customobject.html index 947bb03..b0a8d64 100644 --- a/netbox_custom_objects_tab/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects_tab/templates/netbox_custom_objects/customobject.html @@ -10,162 +10,203 @@ {% load i18n %} {% load custom_object_utils %} {% load custom_object_tab_tags %} -{% block extra_controls %}{% endblock %} + {% block breadcrumbs %} - - + + {% endblock breadcrumbs %} + {% block object_identifier %} - {{ object|meta:"app_label" }}.{{ object.custom_object_type.slug }}:{{ object.pk }} - {% if object.slug %}({{ object.slug }}){% endif %} +{{ object|meta:"app_label" }}.{{ object.custom_object_type.slug }}:{{ object.pk }} +{% if object.slug %}({{ object.slug }}){% endif %} {% endblock object_identifier %} -{% block title %} - {{ object }} -{% endblock title %} + +{% block title %}{{ object }}{% endblock title %} + {% block subtitle %} -
- {% trans "Created" %} {{ object.created|isodatetime:"minutes" }} - {% if object.last_updated %} - · - {% trans "Updated" %} {{ object.last_updated|isodatetime:"minutes" }} - {% endif %} -
+
+ {% if object.owner %} + {{ object.owner|linkify }} + · + {% endif %} + {% trans "Created" %} {{ object.created|isodatetime:"minutes" }} + {% if object.last_updated %} + · + {% trans "Updated" %} {{ object.last_updated|isodatetime:"minutes" }} + {% endif %} +
{% endblock subtitle %} + {% block controls %} -
- {% block control-buttons %} - {# Default buttons #} - {% if perms.extras.add_bookmark and object.bookmarks %} - {% custom_object_bookmark_button object %} - {% endif %} - {% if perms.extras.add_subscription and object.subscriptions %} - {% custom_object_subscribe_button object %} - {% endif %} - {% if request.user|can_add:object %} - {% custom_object_clone_button object %} - {% endif %} - {% if request.user|can_change:object %} - {% custom_object_edit_button object %} - {% endif %} - {% if request.user|can_delete:object %} - {% custom_object_delete_button object %} - {% endif %} - {% endblock %} -
- {# Custom links #} -
-
- {% block custom-links %} - {% custom_links object %} - {% endblock custom-links %} -
+
+ {% block control-buttons %} + {% block extra_controls %}{% endblock %} + {# Default buttons #} + {% if perms.extras.add_bookmark and object.bookmarks %} + {% custom_object_bookmark_button object %} + {% endif %} + {% if perms.extras.add_subscription and object.subscriptions %} + {% custom_object_subscribe_button object %} + {% endif %} + {% if request.user|can_add:object %} + {% custom_object_clone_button object %} + {% endif %} + {% if request.user|can_change:object %} + {% custom_object_edit_button object %} + {% endif %} + {% if request.user|can_delete:object %} + {% custom_object_delete_button object %} + {% endif %} + {% endblock %} +
+ + {# Custom links #} +
+
+ {% block custom-links %} + {% custom_links object %} + {% endblock custom-links %}
+
{% endblock controls %} + {% block tabs %} - + {% endblock tabs %} + {% block content %} -
-
-
- - - - - - - - - - {% for group_name, group_fields in field_groups.items %} - {% if group_name %} - - - - {% endif %} - {% for field in group_fields %} - {% with is_visible_in_ui=object|get_field_is_ui_visible:field %} - {% if field.is_single_value and is_visible_in_ui %} - - - - - {% endif %} +
+
+
+
{% trans "Type" %}{{ object.custom_object_type|linkify:"display_name" }}
{% trans "Last activity" %} - {{ latest_change.time|isodatetime|placeholder }} - {% if latest_change %} -
{{ latest_change.time|timesince }} {% trans "ago" %}
- {% endif %} -
{{ group_name }}
- {{ field }} - {% if field.description %} - - {% endif %} - - {% with customfield=field value=object|get_field_value:field %} - {% include "builtins/customfield_value.html" %} - {% endwith %} -
+ + + + + + + + + {% for group_name, group_fields in field_groups.items %} + {% if group_name %} + + + + {% endif %} + {% for field in group_fields %} + {% with is_visible_in_ui=object|get_field_is_ui_visible:field %} + {% if field.is_single_value and is_visible_in_ui %} + + +
{% trans "Type" %} + {{ object.custom_object_type|linkify:"display_name" }} +
{% trans "Last activity" %} + {{ latest_change.time|isodatetime|placeholder }} + {% if latest_change %} +
{{ latest_change.time|timesince }} {% trans "ago" %}
+ {% endif %} +
{{ group_name }}
{{ field }} + {% if field.description %} + + {% endif %} + + {% if field.type == 'coordinates' %} + {% with coords=object|get_field_value:field %} + {% if coords %} + {{ coords }} + {% with map_url=object|get_coordinate_map_url:field %} + {% if map_url %} + + {% trans "Map" %} + + {% endif %} {% endwith %} - {% endfor %} - {% endfor %} -
-
- {% plugin_left_page object %} -
-
- {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} - {% for group_name, group_fields in field_groups.items %} - {% for field in group_fields %} - {% if field.many %} - {% with field_values=object|get_child_relations:field is_visible_in_ui=object|get_field_is_ui_visible:field %} - {% if is_visible_in_ui %} -
-

- {% if group_name %}{{ group_name }}:{% endif %} - {{ field }} -

- - {% for relation in field_values.all %} - - - - {% endfor %} -
{{ relation|linkify }}
-
- {% endif %} + {% else %} + {{ ''|placeholder }} + {% endif %} {% endwith %} - {% endif %} - {% endfor %} + {% else %} + {% customfield_value field object|get_field_value:field %} + {% endif %} + + + {% endif %} + {% endwith %} {% endfor %} -
+ {% endfor %} + +
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} + {% for group_name, group_fields in field_groups.items %} + {% for field in group_fields %} + {% if field.many %} + {% with field_values=object|get_child_relations:field is_visible_in_ui=object|get_field_is_ui_visible:field %} + {% if is_visible_in_ui %} +
+

+ {% if group_name %}{{ group_name }}: {% endif %}{{ field }} +

+ + {% if field.is_polymorphic %} + + + + + + + {% endif %} + {% for relation in field_values.all %} + + {% if field.is_polymorphic %} + + {% endif %} + + + {% endfor %} +
{% trans "Type" %}{% trans "Object" %}
{{ relation|meta:"verbose_name" }}{{ relation|linkify }}
+
+ {% endif %} + {% endwith %} + {% endif %} + {% endfor %} + {% endfor %}
-
-
{% plugin_full_width_page object %}
+
+
+
+ {% plugin_full_width_page object %}
+
{% endblock %} From 9e1e28b68ca9eeba8563c95e1ac7857deebb78ae Mon Sep 17 00:00:00 2001 From: Jan Krupa Date: Mon, 24 Aug 2026 09:09:01 +0200 Subject: [PATCH 2/4] Reuse upstream build_filterset_form_class; fall back to generic/object.html base template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes in the typed/combined view plumbing: - Replace the local filter-form replica in typed.py with upstream's build_filterset_form_class (netbox_custom_objects.dynamic_forms) — the same builder CustomObjectListView uses. The replica assigned the dict returned by polymorphic get_filterform_field() (one form field per allowed target type) to a single attribute, so polymorphic filters never rendered in the typed-tab sidebar. Also gains the 0.6.0 owner filter for free. - _get_base_template() built "{app}/{model}.html" unconditionally, but not every model has one (ipam/vrf.html and dcim/macaddress.html don't exist), so {% extends base_template %} raised TemplateDoesNotExist (HTTP 500) on those models' tab pages. Delegate to NetBox's utilities.views.get_default_template(), which falls back to generic/object.html — the same resolution Journal/Changelog tabs use. Affected both combined and typed tabs. --- netbox_custom_objects_tab/views/_co_common.py | 7 +- netbox_custom_objects_tab/views/typed.py | 35 +------ tests/conftest.py | 14 ++- tests/test_views_typed_smoke.py | 93 +++---------------- 4 files changed, 36 insertions(+), 113 deletions(-) diff --git a/netbox_custom_objects_tab/views/_co_common.py b/netbox_custom_objects_tab/views/_co_common.py index ebaca30..5b0a61c 100644 --- a/netbox_custom_objects_tab/views/_co_common.py +++ b/netbox_custom_objects_tab/views/_co_common.py @@ -1,3 +1,5 @@ +from utilities.views import get_default_template + _CUSTOM_OBJECTS_APP = "netbox_custom_objects" # Dynamic CO models use a single shared detail template; per-model templates don't exist. _CO_BASE_TEMPLATE = "netbox_custom_objects/customobject.html" @@ -7,4 +9,7 @@ def _get_base_template(instance): """Return the correct base_template for an object's detail page.""" if instance._meta.app_label == _CUSTOM_OBJECTS_APP: return _CO_BASE_TEMPLATE - return f"{instance._meta.app_label}/{instance._meta.model_name}.html" + # Not every model has an "{app}/{model}.html" detail template (e.g. ipam/vrf.html + # and dcim/macaddress.html don't exist). get_default_template falls back to + # generic/object.html — the same resolution NetBox's Journal/Changelog tabs use. + return get_default_template(instance._meta.model) diff --git a/netbox_custom_objects_tab/views/typed.py b/netbox_custom_objects_tab/views/typed.py index 3846a93..5bc2020 100644 --- a/netbox_custom_objects_tab/views/typed.py +++ b/netbox_custom_objects_tab/views/typed.py @@ -10,13 +10,12 @@ from django.urls import NoReverseMatch, reverse from django.views.generic import View from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices -from netbox.forms import NetBoxModelFilterSetForm from netbox.registry import registry from netbox_custom_objects import field_types +from netbox_custom_objects.dynamic_forms import build_filterset_form_class from netbox_custom_objects.filtersets import get_filterset_class from netbox_custom_objects.models import CustomObjectTypeField from netbox_custom_objects.tables import CustomObjectTable -from utilities.forms.fields import TagFilterField from utilities.views import ViewTab, register_model_view from ._co_common import _CO_BASE_TEMPLATE, _CUSTOM_OBJECTS_APP, _get_base_template # noqa: F401 @@ -123,31 +122,6 @@ def _build_typed_table_class(custom_object_type, dynamic_model): ) -def _build_filterset_form(custom_object_type, dynamic_model): - """ - Dynamically build a filterset form class for a Custom Object Type. - Replicates CustomObjectListView.get_filterset_form() logic. - """ - attrs = { - "model": dynamic_model, - "__module__": "database.filterset_forms", - "tag": TagFilterField(dynamic_model), - } - - for field in custom_object_type.fields.all(): - field_type = field_types.FIELD_TYPE_CLASS[field.type]() - try: - attrs[field.name] = field_type.get_filterform_field(field) - except NotImplementedError: - logger.debug("typed tab: %s filter field not supported", field.name) - - return type( - f"{dynamic_model._meta.object_name}FilterForm", - (NetBoxModelFilterSetForm,), - attrs, - ) - - def _build_add_links(custom_object_type_slug, host_instance, field_infos, return_url): """ Build pre-filled "Add" URLs for the native customobject_add view. @@ -333,9 +307,10 @@ def get(self, request, pk, **kwargs): filterset = filterset_class(request.GET, queryset=base_qs) filtered_qs = filterset.qs - # Build filterset form for the filter sidebar - filterset_form_class = _build_filterset_form(cot, dynamic_model) - filter_form = filterset_form_class(request.GET) + # Filter sidebar form: reuse upstream's builder (same one CustomObjectListView + # uses) — handles polymorphic fields, which expand into one form field per + # allowed target type, and adds the 0.6.0 owner filter. + filter_form = build_filterset_form_class(dynamic_model)(request.GET) # Build table class and instantiate table_class = _build_typed_table_class(cot, dynamic_model) diff --git a/tests/conftest.py b/tests/conftest.py index 934eac8..67f1e03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,7 +74,14 @@ class _CustomFieldUIVisibleChoices: # --- utilities.* --- _mock("utilities") -_mock("utilities.views", ViewTab=MagicMock(), register_model_view=MagicMock()) +# get_default_template mirrors NetBox's real fallback contract: "{app}/{model}.html" +# when that template exists, else "generic/object.html". Tests patch it as needed. +_mock( + "utilities.views", + ViewTab=MagicMock(), + register_model_view=MagicMock(), + 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()) _mock("utilities.htmx", htmx_partial=MagicMock()) _mock("utilities.forms") @@ -129,5 +136,10 @@ def _set_columns(self, selected_columns): _mock("netbox_custom_objects.models", CustomObjectTypeField=MagicMock()) _mock("netbox_custom_objects.field_types", FIELD_TYPE_CLASS={}) _mock("netbox_custom_objects.filtersets", get_filterset_class=MagicMock()) +_mock("netbox_custom_objects.dynamic_forms", build_filterset_form_class=MagicMock()) +_mock( + "netbox_custom_objects.choices", + CustomObjectFieldTypeChoices=MagicMock(TYPE_COORDINATES="coordinates"), +) _CustomObjectTable = type("CustomObjectTable", (), {}) _mock("netbox_custom_objects.tables", CustomObjectTable=_CustomObjectTable) diff --git a/tests/test_views_typed_smoke.py b/tests/test_views_typed_smoke.py index f2b65f6..f968473 100644 --- a/tests/test_views_typed_smoke.py +++ b/tests/test_views_typed_smoke.py @@ -250,86 +250,6 @@ def test_not_implemented_column_logged_and_skipped(self, caplog): assert issubclass(table_cls, CustomObjectTable) -# --------------------------------------------------------------------------- -# _build_filterset_form -# --------------------------------------------------------------------------- -class TestBuildFiltersetForm: - def _make_cot_and_model(self, field_specs): - fields = [] - for spec in field_specs: - f = MagicMock() - f.name = spec["name"] - f.type = spec.get("type", CustomFieldTypeChoices.TYPE_TEXT) - fields.append(f) - - cot = MagicMock() - cot.fields.all.return_value = fields - - dynamic_model = MagicMock() - dynamic_model._meta.object_name = "TestDynModel" - - return cot, dynamic_model - - def test_inherits_from_netbox_model_filter_set_form(self): - from netbox.forms import NetBoxModelFilterSetForm - - from netbox_custom_objects_tab.views.typed import _build_filterset_form - - cot, model = self._make_cot_and_model([]) - form_cls = _build_filterset_form(cot, model) - assert issubclass(form_cls, NetBoxModelFilterSetForm) - - def test_tag_field_present(self): - from netbox_custom_objects_tab.views.typed import _build_filterset_form - - cot, model = self._make_cot_and_model([]) - form_cls = _build_filterset_form(cot, model) - assert hasattr(form_cls, "tag") - - def test_get_filterform_field_called_per_field(self): - from netbox_custom_objects_tab.views.typed import _build_filterset_form - - ft_instance = MagicMock() - ft_instance.get_filterform_field.return_value = MagicMock() - ft_mock = MagicMock(return_value=ft_instance) - - with patch.dict( - "netbox_custom_objects.field_types.FIELD_TYPE_CLASS", - { - CustomFieldTypeChoices.TYPE_TEXT: ft_mock, - }, - ): - cot, model = self._make_cot_and_model( - [ - {"name": "field_a", "type": CustomFieldTypeChoices.TYPE_TEXT}, - {"name": "field_b", "type": CustomFieldTypeChoices.TYPE_TEXT}, - ] - ) - _build_filterset_form(cot, model) - - assert ft_instance.get_filterform_field.call_count == 2 - - def test_not_implemented_filter_logged_and_skipped(self, caplog): - from netbox_custom_objects_tab.views.typed import _build_filterset_form - - ft_instance = MagicMock() - ft_instance.get_filterform_field.side_effect = NotImplementedError - ft_mock = MagicMock(return_value=ft_instance) - - with ( - patch.dict("netbox_custom_objects.field_types.FIELD_TYPE_CLASS", {"custom_type": ft_mock}), - caplog.at_level(logging.DEBUG, logger="netbox_custom_objects_tab"), - ): - cot, model = self._make_cot_and_model( - [ - {"name": "weird_field", "type": "custom_type"}, - ] - ) - form_cls = _build_filterset_form(cot, model) - - assert not hasattr(form_cls, "weird_field") - - # --------------------------------------------------------------------------- # register_typed_tabs # --------------------------------------------------------------------------- @@ -525,6 +445,9 @@ def _make_instance(self, app_label, model_name): instance = MagicMock() instance._meta.app_label = app_label instance._meta.model_name = model_name + # _get_base_template passes instance._meta.model to get_default_template + instance._meta.model._meta.app_label = app_label + instance._meta.model._meta.model_name = model_name return instance def test_co_model_returns_shared_template(self): @@ -533,12 +456,20 @@ def test_co_model_returns_shared_template(self): instance = self._make_instance("netbox_custom_objects", "table28model") assert _get_base_template(instance) == _CO_BASE_TEMPLATE - def test_non_co_model_returns_per_model_template(self): + def test_non_co_model_delegates_to_get_default_template(self): from netbox_custom_objects_tab.views.typed import _get_base_template instance = self._make_instance("dcim", "device") assert _get_base_template(instance) == "dcim/device.html" + def test_non_co_model_without_detail_template_falls_back_to_generic(self): + from netbox_custom_objects_tab.views import _co_common + from netbox_custom_objects_tab.views.typed import _get_base_template + + instance = self._make_instance("ipam", "vrf") + with patch.object(_co_common, "get_default_template", return_value="generic/object.html"): + assert _get_base_template(instance) == "generic/object.html" + # --------------------------------------------------------------------------- # _build_add_links From 539c3f5dae5eaf368e98592aba3761378e09ea34 Mon Sep 17 00:00:00 2001 From: Jan Krupa Date: Mon, 24 Aug 2026 09:09:08 +0200 Subject: [PATCH 3/4] Add owner column, filter, and sort to the combined tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the 0.6.0 ownership field: sortable Owner column (hideable via Configure Table, default on), an owner dropdown next to the tag filter, and owner in the sort keys. Rows fetch owner via select_related — no extra queries (OwnerMixin is on the CustomObject base, so every dynamic model has the FK). Also fixes the Field column sort link, which rendered as literal text due to a mismatched template brace ({{ sort_headers.field.url %}). --- .../combined/tab.html | 20 ++++++++++++- .../combined/tab_partial.html | 13 +++++++-- netbox_custom_objects_tab/views/combined.py | 28 ++++++++++++++++--- tests/test_views_combined.py | 4 +-- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html index ef9c976..32ebfe6 100644 --- a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html +++ b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html @@ -22,7 +22,7 @@

{% trans "Custom Objects" %}

- {% if q or type_slug or tag_slug %} + {% if q or type_slug or tag_slug or owner_id %} @@ -65,6 +65,24 @@

{% trans "Custom Objects" %}

{% endfor %} {% endif %} + {# Owner dropdown — only shown when any object has an owner #} + {% if available_owners %} + + {% endif %} {# Preserve sort state and per_page when submitting the search form #} {% if sort %}{% endif %} {% if sort_dir and sort_dir != 'asc' %}{% endif %} diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html index b7a4505..07881c0 100644 --- a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html +++ b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html @@ -29,9 +29,17 @@ {% if 'value' in selected_columns %} {% trans "Value" %} {% endif %} + {% if 'owner' in selected_columns %} + +
+ {% trans "Owner" %} + {% if sort_headers.owner.icon %}{% endif %} + + + {% endif %} {% if 'field' in selected_columns %} - + {% trans "Field" %} {% if sort_headers.field.icon %}{% endif %} @@ -83,6 +91,7 @@ {% endif %} {% endif %} + {% if 'owner' in selected_columns %}{{ obj.owner|linkify|placeholder }}{% endif %} {% if 'field' in selected_columns %}{{ field }}{% endif %} {% if 'tags' in selected_columns %} @@ -117,7 +126,7 @@ {% else %}
- {% if q or type_slug or tag_slug %} + {% if q or type_slug or tag_slug or owner_id %} {% trans "No custom objects match your filters." %} {% else %} {% trans "No custom objects are linked to this object." %} diff --git a/netbox_custom_objects_tab/views/combined.py b/netbox_custom_objects_tab/views/combined.py index c92f1ed..78e9c92 100644 --- a/netbox_custom_objects_tab/views/combined.py +++ b/netbox_custom_objects_tab/views/combined.py @@ -27,6 +27,7 @@ class CustomObjectsTabTable(BaseTable): type = tables2.Column(verbose_name=_("Type"), orderable=False) object = tables2.Column(verbose_name=_("Object"), orderable=False) value = tables2.Column(verbose_name=_("Value"), orderable=False) + owner = tables2.Column(verbose_name=_("Owner"), orderable=False) field = tables2.Column(verbose_name=_("Field"), orderable=False) tags = tables2.Column(verbose_name=_("Tags"), orderable=False) actions = tables2.Column(verbose_name="", orderable=False) @@ -34,8 +35,8 @@ class CustomObjectsTabTable(BaseTable): exempt_columns = ("actions",) class Meta(BaseTable.Meta): - fields = ("type", "object", "value", "field", "tags", "actions") - default_columns = ("type", "object", "value", "field", "tags", "actions") + fields = ("type", "object", "value", "owner", "field", "tags", "actions") + default_columns = ("type", "object", "value", "owner", "field", "tags", "actions") # Maximum number of related objects to show in the Value column for MULTIOBJECT fields. @@ -118,7 +119,9 @@ def _get_linked_custom_objects(instance): """ results = [] for field, model, filter_kwargs in _iter_linked_fields(instance): - for obj in model.objects.filter(**filter_kwargs).prefetch_related("tags"): + # select_related("owner"): OwnerMixin is on the CustomObject base class + # (netbox-custom-objects 0.6.0+), so every dynamic model has the FK. + for obj in model.objects.filter(**filter_kwargs).select_related("owner").prefetch_related("tags"): results.append((obj, field)) return results @@ -173,6 +176,7 @@ def _get_field_value(obj, field): _SORT_KEYS = { "type": lambda t: str(t[1].custom_object_type).lower(), "object": lambda t: str(t[0]).lower(), + "owner": lambda t: str(t[0].owner or "").lower(), "field": lambda t: str(t[1]).lower(), } @@ -252,6 +256,7 @@ def get(self, request, pk, **kwargs): q = request.GET.get("q", "") type_slug = request.GET.get("type", "") tag_slug = request.GET.get("tag", "").strip() + owner_id = request.GET.get("owner", "").strip() sort_col = request.GET.get("sort", "") sort_dir = request.GET.get("dir", "asc") per_page = request.GET.get("per_page", "") @@ -266,12 +271,23 @@ def get(self, request, pk, **kwargs): available_tags.append(t) available_tags.sort(key=lambda t: t.name.lower()) + # Collect unique owners for the dropdown (always from the unfiltered list) + seen_owner_pks = set() + available_owners = [] + for _obj, _field in linked_all: + if (owner := _obj.owner) and owner.pk not in seen_owner_pks: + seen_owner_pks.add(owner.pk) + available_owners.append(owner) + available_owners.sort(key=lambda o: str(o).lower()) + # Apply filters linked = _filter_linked_objects(linked_all, q) if type_slug: linked = [(obj, field) for obj, field in linked if field.custom_object_type.slug == type_slug] if tag_slug: linked = [(obj, field) for obj, field in linked if tag_slug in {t.slug for t in obj.tags.all()}] + if owner_id: + linked = [(obj, field) for obj, field in linked if str(obj.owner_id or "") == owner_id] # In-memory sort (applied after filters, before pagination) if sort_col in _SORT_KEYS: @@ -295,12 +311,14 @@ def get(self, request, pk, **kwargs): base_params["type"] = type_slug if tag_slug: base_params["tag"] = tag_slug + if owner_id: + base_params["owner"] = owner_id if per_page: base_params["per_page"] = per_page sort_base = urlencode(base_params) sort_headers = { - col: _sort_header(sort_base, col, sort_col, sort_dir) for col in ("type", "object", "field") + col: _sort_header(sort_base, col, sort_col, sort_dir) for col in ("type", "object", "owner", "field") } context = { @@ -315,8 +333,10 @@ def get(self, request, pk, **kwargs): "q": q, "type_slug": type_slug, "tag_slug": tag_slug, + "owner_id": owner_id, "available_types": available_types, "available_tags": available_tags, + "available_owners": available_owners, "sort": sort_col, "sort_dir": sort_dir, "sort_headers": sort_headers, diff --git a/tests/test_views_combined.py b/tests/test_views_combined.py index c9d788d..e4b1424 100644 --- a/tests/test_views_combined.py +++ b/tests/test_views_combined.py @@ -166,8 +166,8 @@ def table_cls(self): self.cls = CustomObjectsTabTable - def test_default_columns_contains_all_six(self): - assert set(self.cls.Meta.default_columns) == {"type", "object", "value", "field", "tags", "actions"} + def test_default_columns_contains_all_seven(self): + assert set(self.cls.Meta.default_columns) == {"type", "object", "value", "owner", "field", "tags", "actions"} def test_actions_is_exempt(self): assert "actions" in self.cls.exempt_columns From 783f65a3436f8db041dea6b986bfb8d9151f5ef0 Mon Sep 17 00:00:00 2001 From: Jan Krupa Date: Mon, 24 Aug 2026 09:09:14 +0200 Subject: [PATCH 4/4] Release 2.5.0 CHANGELOG entry, README compatibility matrix (2.5.x requires netbox-custom-objects >=0.6.0; 0.5.x installs stay on 2.4.1), and version bump. --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 19 +++++++++------- pyproject.toml | 2 +- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02be233..8bdf1fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,65 @@ 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.5.0] - 2026-08-24 + +### Changed + +- **netbox-custom-objects compatibility floor raised to 0.6.0** + ([release notes](https://github.com/netboxlabs/netbox-custom-objects/releases/tag/v0.6.0)). + The `PluginConfig.ready()` probe now checks for + `CustomObjectFieldTypeChoices.TYPE_COORDINATES` (added in 0.6.0) and + raises `ImproperlyConfigured` pointing at + `pip install -U 'netbox-custom-objects>=0.6.0'`. 0.5.x installs must + stay on plugin 2.4.1. NetBox floors are unchanged (4.5.2 – 4.6.99, + matching upstream 0.6.x). +- **Regenerated the `customobject.html` template override from the + 0.6.0 stock template.** The 2.4.x copy was based on 0.5.x and silently + dropped upstream features when run against 0.6.0. The refresh restores: + Contacts tab, Config Context tab (shown when the type has + `config_context_enabled`), owner display in the page header, + `coordinates` field rendering with a Map button, the polymorphic + M2M "Type" column in many-field cards, and the switch to the + `{% customfield_value %}` builtin tag. Our two insertions (the + `custom_object_tab_tags` load and `{% plugin_extra_tabs object %}` + between Contacts and Journal) are the only deltas from stock, and the + file intentionally keeps upstream formatting so future refreshes are a + plain `diff` against the new stock template. +- **Typed-tab filter sidebar now reuses upstream's + `build_filterset_form_class()`** (`netbox_custom_objects.dynamic_forms`) + instead of a local replica — the same builder `CustomObjectListView` + uses, so the sidebar automatically gains the 0.6.0 owner filter. + +### Added + +- **Owner column and filter in the combined tab.** New "Owner" column + (sortable, hideable via Configure Table) showing the 0.6.0 ownership + field, plus an owner dropdown filter next to the tag filter. Owner rows + are fetched with `select_related("owner")` — no extra queries. Note: + the text search (`q`) still matches object/type/field only, not owner + names. + +### Fixed + +- **`TemplateDoesNotExist` (HTTP 500) on tab pages of models without a + per-model detail template** — e.g. `/ipam/vrfs//custom-objects-/` + crashed with `TemplateDoesNotExist: ipam/vrf.html`, likewise MAC + addresses (`dcim/macaddress.html`) and any other model NetBox renders + via the generic detail view. `_get_base_template()` built + `"{app}/{model}.html"` unconditionally; it now delegates to NetBox's + `utilities.views.get_default_template()`, which falls back to + `generic/object.html` — the same resolution NetBox's own + Journal/Changelog tabs use. Affected both combined and typed tabs. +- **Polymorphic filter fields missing from the typed-tab sidebar.** The + local filter-form replica assigned the dict returned by polymorphic + `get_filterform_field()` (one form field per allowed target type) to a + single form attribute, so those filters never rendered. Fixed by the + switch to upstream's `build_filterset_form_class()`, which expands the + dict correctly. +- **Field column sort link broken in the combined tab** — a mismatched + template brace (`{{ sort_headers.field.url %}`) rendered the URL + expression as literal text instead of the sort link. + ## [2.4.1] - 2026-05-25 ### Changed diff --git a/README.md b/README.md index be05321..97518e0 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,14 @@ Two tab modes are available: ## Requirements - NetBox 4.5.2 – 4.6.99 -- `netbox_custom_objects` plugin **≥ 0.5.1** installed and configured - (0.5.0 had an upstream Delete bug fixed in 0.5.1; see [Known Issues](#known-issues)) +- `netbox_custom_objects` plugin **≥ 0.6.0** installed and configured + (0.5.x installs must stay on plugin 2.4.1) ## Compatibility | Plugin version | NetBox version | `netbox_custom_objects` version | |----------------|----------------|------------------------------------------------------------------------| +| 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) | | 2.2.x | 4.5.4+ / 4.6.x | ≥ 0.4.6 (≥ 0.5.0 on 4.6) | @@ -41,13 +42,15 @@ Two tab modes are available: | 2.0.x | 4.5.x | ≥ 0.4.6 | | 1.0.x | 4.5.x | ≥ 0.4.4 | -Plugin 2.4.x **enforces** the 0.5.1 minimum at startup: `PluginConfig.ready()` -probes for the upstream `is_polymorphic` model field (introduced in 0.5.0) +Plugin 2.5.x **enforces** the 0.6.0 minimum at startup: `PluginConfig.ready()` +probes for the upstream `coordinates` field type (introduced in 0.6.0) and raises `ImproperlyConfigured` with an upgrade message pointing at -`>=0.5.1` if the installed upstream is older. The check is behaviour-based -(looks for the field, not a version string) so it stays correct across forks -and pre-release tags; the message advances to 0.5.1 because 2.4.x assumes -the bug fixes shipped in that release. +`>=0.6.0` if the installed upstream is older. The check is behaviour-based +(looks for the feature, not a version string) so it stays correct across forks +and pre-release tags. The hard floor exists because the plugin ships a copy of +the 0.6.0 `customobject.html` detail template (Contacts and Config Context +tabs, owner display, coordinates rendering), which reverses URLs that do not +exist on 0.5.x. ## Installation diff --git a/pyproject.toml b/pyproject.toml index 3574610..7bff66c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-custom-objects-tab" -version = "2.4.1" +version = "2.5.0" description = "NetBox plugin that adds a Custom Objects tab to object detail pages" readme = "README.md" requires-python = ">=3.12"