Skip to content

Commit 37ccf6b

Browse files
author
Jan Krupa
committed
Add CO→CO tab support and simplify combined view helpers
Enable tabs on Custom Object detail pages referencing other Custom Objects (netbox_custom_objects.* wildcard). Handles dynamic model resolution, URL injection, and template override for the hardcoded tabs block. Extract _iter_linked_fields() generator to deduplicate field-discovery logic shared by _get_linked_custom_objects and _count_linked_custom_objects. Remove unused _CO_BASE_TEMPLATE import from combined.py. Update README: add 2.1.x compatibility row, mention CO→CO in description.
1 parent 421df7a commit 37ccf6b

13 files changed

Lines changed: 524 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.1.0] - 2026-03-16
9+
10+
### Added
11+
12+
- **CO→CO tabs**`netbox_custom_objects.*` is now a valid value for both `combined_models`
13+
and `typed_models`. This enables tabs on Custom Object detail pages themselves: when
14+
Custom Object Type A has a field (FK or M2M) pointing to Custom Object Type B, navigating
15+
to a Type B instance shows a tab listing all Type A instances that reference it.
16+
A NetBox restart is required whenever a new Custom Object Type is added (same requirement
17+
as all typed tabs).
18+
- `template_override.py` — prepends our templates directory to Django's filesystem loader
19+
at `ready()` time so that our `netbox_custom_objects/customobject.html` override (which
20+
adds `{% model_view_tabs object %}`) is found before the original template.
21+
22+
### Fixed
23+
24+
- Tab views now accept `**kwargs` in their `get()` method, accommodating the extra
25+
`custom_object_type` URL keyword argument present on Custom Object detail URLs.
26+
- `base_template` for Custom Object model instances now correctly resolves to
27+
`netbox_custom_objects/customobject.html` instead of the nonexistent per-model template.
28+
- `_inject_co_urls()` appends the necessary URL patterns for CO tab views into
29+
`netbox_custom_objects.urls` at startup, enabling URL reversal for registered tabs
30+
(the `netbox_custom_objects` plugin uses a single generic view and never registers
31+
per-model URL patterns for dynamic models).
32+
833
## [2.0.2] - 2026-03-06
934

1035
### Fixed

CLAUDE.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,16 @@ Both modes coexist. Config variables control which models get which behavior.
4242

4343
| File | Role |
4444
|------|------|
45-
| `netbox_custom_objects_tab/__init__.py` | `PluginConfig`; calls `views.register_tabs()` in `ready()` |
46-
| `netbox_custom_objects_tab/views/__init__.py` | `register_tabs()` + `_resolve_model_labels()` helper |
45+
| `netbox_custom_objects_tab/__init__.py` | `PluginConfig`; calls `template_override.install()` then `views.register_tabs()` in `ready()` |
46+
| `netbox_custom_objects_tab/template_override.py` | Prepends our `templates/` dir to `engine.dirs` so CO detail template override is found first |
47+
| `netbox_custom_objects_tab/views/__init__.py` | `register_tabs()` + `_resolve_model_labels()` + `_inject_co_urls()` |
4748
| `netbox_custom_objects_tab/views/combined.py` | Combined-tab view factory + helpers |
4849
| `netbox_custom_objects_tab/views/typed.py` | Per-type tab view factory + dynamic table/filterset builders |
4950
| `netbox_custom_objects_tab/urls.py` | Empty `urlpatterns` (required by NetBox plugin loader) |
5051
| `templates/.../combined/tab.html` | Combined tab full page (extends base_template) |
5152
| `templates/.../combined/tab_partial.html` | Combined tab HTMX zone (no extends) |
5253
| `templates/.../typed/tab.html` | Typed tab full page (extends base_template, mirrors `generic/object_list.html`) |
54+
| `templates/netbox_custom_objects/customobject.html` | Override of CO detail template — adds `{% model_view_tabs object %}` to the hardcoded tabs block |
5355

5456
## Config Design
5557

@@ -153,6 +155,33 @@ Key functions in `views/typed.py`:
153155
HTMX for typed tabs: the view returns `htmx/table.html` (NetBox standard) for HTMX requests.
154156
No custom partial needed — `table.configure(request)` handles pagination and ordering.
155157

158+
## CO→CO Tab Support (`netbox_custom_objects.*`)
159+
160+
Setting `netbox_custom_objects.*` in `combined_models` or `typed_models` enables tabs on
161+
Custom Object detail pages themselves (e.g. Type A has a FK to Type B → Type B's detail page
162+
shows a tab of Type A instances).
163+
164+
Three non-obvious problems had to be solved:
165+
166+
1. **Model resolution** — dynamic per-type models (e.g. `Table28Model`) are not returned by
167+
`apps.get_app_config().get_models()` unless already registered. `_resolve_model_labels()`
168+
special-cases `netbox_custom_objects.*` to read `CustomObject` subclasses from
169+
`app_config.get_models()` (safe after `netbox_custom_objects.ready()` has run).
170+
**Never call `get_model()` here** — it re-registers journal/changelog views on cache miss.
171+
172+
2. **URL patterns**`netbox_custom_objects` serves all CO detail pages via one generic
173+
`CustomObjectView` and never calls `get_model_urls()` for dynamic models. Our tab views
174+
are registered in `registry['views']` but have no corresponding URL patterns, so
175+
`get_action_url()` throws `NoReverseMatch` (silently skipped by the template tag).
176+
`_inject_co_urls()` appends patterns like
177+
`<str:custom_object_type>/<int:pk>/custom-objects-{slug}/` to
178+
`netbox_custom_objects.urls.urlpatterns` at `ready()` time.
179+
180+
3. **Template**`netbox_custom_objects/customobject.html` has a hardcoded `{% block tabs %}`
181+
with no `{% model_view_tabs object %}` call. `template_override.install()` prepends our
182+
`templates/` directory to `engine.dirs` so our copy of the template (with the call added)
183+
is found first by the filesystem loader.
184+
156185
## Permission Checks in Template
157186

158187
Combined tab uses inline `<a>` buttons with `can_change`/`can_delete` filters (see combined templates).
@@ -179,7 +208,12 @@ permissions internally via `get_permission_for_model()`.
179208
error for dynamic models)
180209
- Typed tabs use `custom-objects-{slug}` path prefix — avoids collisions with built-in paths
181210
- Multiple fields of same type → union querysets with `.distinct()`
182-
- Tabs registered at `ready()` — new Custom Object Types need a restart
211+
- Tabs registered at `ready()` — new Custom Object Types need a restart (applies both to typed tabs on native models and to `netbox_custom_objects.*` tabs on Custom Object pages)
212+
- `netbox_custom_objects.*` wildcard is special-cased in `_resolve_model_labels()` — dynamic models are discovered via `app_config.get_models()` filtered to `CustomObject` subclasses. **Do NOT call `get_model()` here** — each cache-miss call re-registers journal/changelog tab views, producing duplicate tabs
213+
- `base_template` for CO model instances must be `netbox_custom_objects/customobject.html` — the per-model template (e.g. `netbox_custom_objects/table28model.html`) does not exist
214+
- Tab view `get()` must accept `**kwargs` — CO detail URLs pass `custom_object_type` slug as an extra kwarg alongside `pk`
215+
- `netbox_custom_objects/customobject.html` has a **hardcoded** `{% block tabs %}` (Journal + Changelog only) with no `{% model_view_tabs object %}` call. We override it via `template_override.py` + a copy of the template with the call added. The override must be in `engine.dirs` (filesystem loader) not just `app_directories`, because our app comes after `netbox_custom_objects` in `INSTALLED_APPS`
216+
- `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)
183217
- `SavedFiltersMixin` lives at `netbox.forms.mixins`, not `extras.forms.mixins`
184218

185219
## Critical Reference Files

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
[![NetBox](https://img.shields.io/badge/NetBox-4.5.x-blue)](https://github.com/netbox-community/netbox)
77
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
88

9-
A NetBox 4.5.x plugin that adds **Custom Objects** tabs to standard object detail pages,
9+
A NetBox 4.5.x plugin that adds **Custom Objects** tabs to object detail pages,
1010
showing Custom Object instances from the `netbox_custom_objects` plugin that reference
11-
those objects via OBJECT or MULTIOBJECT fields.
11+
those objects via OBJECT or MULTIOBJECT fields. Works on standard NetBox models (Device,
12+
Site, Rack, …), third-party plugin models, and Custom Object detail pages themselves
13+
(CO→CO relationships).
1214

1315
Two tab modes are available:
1416

@@ -31,6 +33,7 @@ Two tab modes are available:
3133

3234
| Plugin version | NetBox version | `netbox_custom_objects` version |
3335
|----------------|----------------|---------------------------------|
36+
| 2.1.x | 4.5.4+ | ≥ 0.4.6 |
3437
| 2.0.x | 4.5.x | ≥ 0.4.6 |
3538
| 1.0.x | 4.5.x | ≥ 0.4.4 |
3639

@@ -93,11 +96,29 @@ A model can appear in both `combined_models` and `typed_models` to get both tab
9396

9497
# Third-party plugin models work identically
9598
'combined_models': ['dcim.*', 'ipam.*', 'inventory_monitor.*']
99+
100+
# Tabs on Custom Object detail pages (CO → CO relationships)
101+
'typed_models': ['netbox_custom_objects.*']
102+
103+
# Combined tab on Custom Object pages + typed tabs on Device pages
104+
'combined_models': ['dcim.*', 'netbox_custom_objects.*'],
105+
'typed_models': ['dcim.*', 'netbox_custom_objects.*'],
96106
```
97107

98108
Third-party plugin models are fully supported — Django treats plugin apps and built-in apps
99109
the same way in the app registry. Add the plugin's app label and restart NetBox once.
100110

111+
#### Tabs on Custom Object detail pages
112+
113+
Setting `netbox_custom_objects.*` in `combined_models` or `typed_models` enables tabs on
114+
Custom Object detail pages themselves. This is useful when one Custom Object Type has a
115+
field referencing another Custom Object Type — the referenced object will show a tab listing
116+
all objects that link to it.
117+
118+
Because Custom Object model classes are generated dynamically (one per type, on-demand),
119+
**a NetBox restart is required whenever a new Custom Object Type is added** — the same
120+
requirement that applies to all typed tabs.
121+
101122
The tab is hidden automatically (`hide_if_empty=True`) when no custom objects reference
102123
the object being viewed, so it only appears when relevant.
103124

netbox_custom_objects_tab/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ class NetBoxCustomObjectsTabConfig(PluginConfig):
3333

3434
def ready(self):
3535
super().ready()
36-
from . import views
36+
from . import template_override, views
3737

38+
template_override.install()
3839
views.register_tabs()
3940

4041

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
Prepend our templates directory to Django's filesystem loader search path.
3+
4+
netbox_custom_objects/customobject.html has a hardcoded {% block tabs %} that
5+
does not call {% model_view_tabs object %}. We ship an override that adds the
6+
call. Because our app comes after netbox_custom_objects in INSTALLED_APPS, the
7+
app_directories loader would find the original first. Prepending to engine.dirs
8+
ensures our override is found first by the filesystem loader, before any templates
9+
are cached.
10+
"""
11+
12+
import logging
13+
import os
14+
15+
logger = logging.getLogger("netbox_custom_objects_tab")
16+
17+
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
18+
19+
20+
def install():
21+
try:
22+
from django.template import engines
23+
24+
engine = engines["django"].engine
25+
26+
# engine.dirs is the filesystem loader's search path.
27+
# Prepending here ensures our override is found before the original in
28+
# netbox_custom_objects (which comes earlier in INSTALLED_APPS).
29+
if _TEMPLATES_DIR not in engine.dirs:
30+
engine.dirs = [_TEMPLATES_DIR] + list(engine.dirs)
31+
logger.debug("prepended templates dir to engine.dirs")
32+
except Exception:
33+
logger.exception("netbox_custom_objects_tab: could not install template override")
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
{% extends 'generic/object.html' %}
2+
{% load static %}
3+
{% load custom_object_buttons %}
4+
{% load custom_links %}
5+
{% load helpers %}
6+
{% load perms %}
7+
{% load plugins %}
8+
{% load render_table from django_tables2 %}
9+
{% load tabs %}
10+
{% load i18n %}
11+
{% load custom_object_utils %}
12+
{% block extra_controls %}{% endblock %}
13+
{% block breadcrumbs %}
14+
<li class="breadcrumb-item">
15+
<a href="{{ object.get_list_url }}">{{ object.custom_object_type.get_verbose_name_plural }}</a>
16+
</li>
17+
<li class="breadcrumb-item">
18+
<a href="{{ object.custom_object_type.get_absolute_url }}">{{ object.custom_object_type }}</a>
19+
</li>
20+
{% endblock breadcrumbs %}
21+
{% block object_identifier %}
22+
{{ object|meta:"app_label" }}.{{ object.custom_object_type.slug }}:{{ object.pk }}
23+
{% if object.slug %}({{ object.slug }}){% endif %}
24+
{% endblock object_identifier %}
25+
{% block title %}
26+
{{ object }}
27+
{% endblock title %}
28+
{% block subtitle %}
29+
<div class="text-secondary fs-5">
30+
{% trans "Created" %} {{ object.created|isodatetime:"minutes" }}
31+
{% if object.last_updated %}
32+
<span class="separator">·</span>
33+
{% trans "Updated" %} {{ object.last_updated|isodatetime:"minutes" }}
34+
{% endif %}
35+
</div>
36+
{% endblock subtitle %}
37+
{% block controls %}
38+
<div class="btn-list justify-content-end mb-2">
39+
{% block control-buttons %}
40+
{# Default buttons #}
41+
{% if perms.extras.add_bookmark and object.bookmarks %}
42+
{% custom_object_bookmark_button object %}
43+
{% endif %}
44+
{% if perms.extras.add_subscription and object.subscriptions %}
45+
{% custom_object_subscribe_button object %}
46+
{% endif %}
47+
{% if request.user|can_add:object %}
48+
{% custom_object_clone_button object %}
49+
{% endif %}
50+
{% if request.user|can_change:object %}
51+
{% custom_object_edit_button object %}
52+
{% endif %}
53+
{% if request.user|can_delete:object %}
54+
{% custom_object_delete_button object %}
55+
{% endif %}
56+
{% endblock %}
57+
</div>
58+
{# Custom links #}
59+
<div class="d-flex justify-content-end">
60+
<div class="btn-list">
61+
{% block custom-links %}
62+
{% custom_links object %}
63+
{% endblock custom-links %}
64+
</div>
65+
</div>
66+
{% endblock controls %}
67+
{% block tabs %}
68+
<ul class="nav nav-tabs" role="presentation">
69+
{# Primary tab #}
70+
<li class="nav-item">
71+
<a class="nav-link{% if not tab %} active{% endif %}"
72+
href="{{ object.get_absolute_url }}">{{ object.custom_object_type.get_verbose_name }}</a>
73+
</li>
74+
{# All other tabs (Journal, Changelog, Custom Objects, typed tabs) come from the registry #}
75+
{% model_view_tabs object %}
76+
</ul>
77+
{% endblock tabs %}
78+
{% block content %}
79+
<div class="row">
80+
<div class="col col-md-6">
81+
<div class="card">
82+
<table class="table table-hover attr-table">
83+
<tr>
84+
<th scope="row">{% trans "Type" %}</th>
85+
<td>{{ object.custom_object_type|linkify:"display_name" }}</td>
86+
</tr>
87+
<tr>
88+
<th scope="row">{% trans "Last activity" %}</th>
89+
<td>
90+
{{ latest_change.time|isodatetime|placeholder }}
91+
{% if latest_change %}
92+
<div class="small text-muted">{{ latest_change.time|timesince }} {% trans "ago" %}</div>
93+
{% endif %}
94+
</td>
95+
</tr>
96+
{% for group_name, group_fields in field_groups.items %}
97+
{% if group_name %}
98+
<tr class="table-group-header">
99+
<th scope="row" colspan="2" class="fw-bold">{{ group_name }}</th>
100+
</tr>
101+
{% endif %}
102+
{% for field in group_fields %}
103+
{% with is_visible_in_ui=object|get_field_is_ui_visible:field %}
104+
{% if field.is_single_value and is_visible_in_ui %}
105+
<tr>
106+
<th scope="row">
107+
{{ field }}
108+
{% if field.description %}
109+
<i class="mdi mdi-information text-primary"
110+
data-bs-toggle="tooltip"
111+
data-bs-placement="right"
112+
title="{{ field.description|escape }}"></i>
113+
{% endif %}
114+
</th>
115+
<td>
116+
{% with customfield=field value=object|get_field_value:field %}
117+
{% include "builtins/customfield_value.html" %}
118+
{% endwith %}
119+
</td>
120+
</tr>
121+
{% endif %}
122+
{% endwith %}
123+
{% endfor %}
124+
{% endfor %}
125+
</table>
126+
</div>
127+
{% plugin_left_page object %}
128+
</div>
129+
<div class="col col-md-6">
130+
{% include 'inc/panels/tags.html' %}
131+
{% plugin_right_page object %}
132+
{% for group_name, group_fields in field_groups.items %}
133+
{% for field in group_fields %}
134+
{% if field.many %}
135+
{% with field_values=object|get_child_relations:field is_visible_in_ui=object|get_field_is_ui_visible:field %}
136+
{% if is_visible_in_ui %}
137+
<div class="card">
138+
<h2 class="card-header">
139+
{% if group_name %}{{ group_name }}:{% endif %}
140+
{{ field }}
141+
</h2>
142+
<table class="table table-hover attr-table">
143+
{% for relation in field_values.all %}
144+
<tr>
145+
<th scope="row">{{ relation|linkify }}</th>
146+
</tr>
147+
{% endfor %}
148+
</table>
149+
</div>
150+
{% endif %}
151+
{% endwith %}
152+
{% endif %}
153+
{% endfor %}
154+
{% endfor %}
155+
</div>
156+
</div>
157+
<div class="row mb-3">
158+
<div class="col col-md-12">{% plugin_full_width_page object %}</div>
159+
</div>
160+
{% endblock %}

0 commit comments

Comments
 (0)