Skip to content

Commit 1e653f7

Browse files
authored
Merge pull request #1 from CESNET/tab_per_type
Add per-type typed tabs (2.0.1)
2 parents 9971e4e + 65073dd commit 1e653f7

18 files changed

Lines changed: 1536 additions & 518 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ db.sqlite3
2929
*.swo
3030

3131
tools/
32+
33+
ignore/

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ 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.0.1] - 2026-02-25
9+
10+
### Added
11+
12+
- **Typed tabs (per-type)** — each Custom Object Type gets its own tab with a full-featured
13+
list view: type-specific columns, filterset sidebar, bulk edit/delete, configure table,
14+
and HTMX pagination.
15+
- `typed_models` and `typed_weight` config settings.
16+
- Third-party plugin model support for both tab modes.
17+
18+
### Changed
19+
20+
- Renamed `models` config to `combined_models`; `label` to `combined_label`; `weight` to
21+
`combined_weight`.
22+
- Refactored views from single `views.py` to `views/` package (`__init__.py`, `combined.py`,
23+
`typed.py`).
24+
- Templates reorganized into `combined/` and `typed/` subdirectories.
25+
26+
### Fixed
27+
28+
- Handle missing database during startup — `register_typed_tabs()` now catches
29+
`OperationalError` and `ProgrammingError` so NetBox can start even when the database
30+
is unavailable or migrations haven't run yet.
31+
- Bulk action return URL in typed tabs — uses query parameter `?return_url=` on `formaction`
32+
for reliable redirect.
33+
834
## [1.0.1] - 2026-02-24
935

1036
### Fixed

CLAUDE.md

Lines changed: 118 additions & 142 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
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 a **Custom Objects** tab to standard object detail pages,
10-
showing any Custom Object instances from the `netbox_custom_objects` plugin that reference
9+
A NetBox 4.5.x plugin that adds **Custom Objects** tabs to standard object detail pages,
10+
showing Custom Object instances from the `netbox_custom_objects` plugin that reference
1111
those objects via OBJECT or MULTIOBJECT fields.
1212

13-
The tab includes **pagination**, **text search**, **column sorting**, **type filtering**,
14-
and **tag filtering**, with HTMX-powered partial updates so table interactions don't reload
15-
the full page.
13+
Two tab modes are available:
14+
15+
- **Combined tab** — a single tab showing all Custom Object Types in one table, with
16+
pagination, text search, column sorting, type/tag filtering, and HTMX partial updates.
17+
- **Typed tabs** — each Custom Object Type gets its own tab with a full-featured list view
18+
(type-specific columns, filterset sidebar, bulk actions, configure table) matching the
19+
native Custom Objects list page.
1620

1721
## Screenshot
1822

@@ -21,13 +25,14 @@ the full page.
2125
## Requirements
2226

2327
- NetBox 4.5.0 – 4.5.99
24-
- `netbox_custom_objects` plugin installed and configured
28+
- `netbox_custom_objects` plugin **≥ 0.4.6** installed and configured
2529

2630
## Compatibility
2731

28-
| Plugin version | NetBox version |
29-
|----------------|----------------|
30-
| 1.0.x | 4.5.x |
32+
| Plugin version | NetBox version | `netbox_custom_objects` version |
33+
|----------------|----------------|---------------------------------|
34+
| 2.0.x | 4.5.x | ≥ 0.4.6 |
35+
| 1.0.x | 4.5.x | ≥ 0.4.4 |
3136

3237
## Installation
3338

@@ -47,9 +52,11 @@ PLUGINS = [
4752
# Optional — defaults shown below
4853
PLUGINS_CONFIG = {
4954
'netbox_custom_objects_tab': {
50-
'models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*'],
51-
'label': 'Custom Objects',
52-
'weight': 2000,
55+
'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'],
56+
'combined_label': 'Custom Objects',
57+
'combined_weight': 2000,
58+
'typed_models': [], # opt-in: e.g. ['dcim.*']
59+
'typed_weight': 2100,
5360
}
5461
}
5562
```
@@ -58,26 +65,34 @@ Restart NetBox. No database migrations required.
5865

5966
## Configuration
6067

61-
| Setting | Default | Description |
62-
|----------|---------|-------------|
63-
| `models` | `['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*']` | Models that get the Custom Objects tab. Accepts `app_label.model_name` strings **or** `app_label.*` wildcards to register every model in an app. |
64-
| `label` | `'Custom Objects'` | Text displayed on the tab. |
65-
| `weight` | `2000` | Controls tab position in the tab bar; lower values appear further left. |
68+
| Setting | Default | Description |
69+
|---------|---------|-------------|
70+
| `combined_models` | `['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*']` | Models that get the combined "Custom Objects" tab. Accepts `app_label.model_name` or `app_label.*` wildcards. |
71+
| `combined_label` | `'Custom Objects'` | Text displayed on the combined tab. |
72+
| `combined_weight` | `2000` | Tab position for the combined tab; lower = further left. |
73+
| `typed_models` | `[]` | Models that get per-type tabs (opt-in, empty by default). Same format as `combined_models`. |
74+
| `typed_weight` | `2100` | Tab position for all typed tabs. |
75+
76+
A model can appear in both `combined_models` and `typed_models` to get both tab styles.
6677

6778
### Examples
6879

6980
```python
70-
# Default — all common NetBox apps
71-
'models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*']
81+
# Combined tab only (default)
82+
'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*']
7283

73-
# Only specific models
74-
'models': ['dcim.device', 'dcim.site', 'ipam.prefix']
84+
# Per-type tabs for dcim models
85+
'typed_models': ['dcim.*']
7586

76-
# Mix wildcards and specifics
77-
'models': ['dcim.*', 'virtualization.*', 'ipam.ipaddress']
87+
# Both modes for dcim, combined only for others
88+
'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'],
89+
'typed_models': ['dcim.*'],
90+
91+
# Only specific models
92+
'combined_models': ['dcim.device', 'dcim.site', 'ipam.prefix']
7893

7994
# Third-party plugin models work identically
80-
'models': ['dcim.*', 'ipam.*', 'inventory_monitor.*']
95+
'combined_models': ['dcim.*', 'ipam.*', 'inventory_monitor.*']
8196
```
8297

8398
Third-party plugin models are fully supported — Django treats plugin apps and built-in apps

TODO.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ Currently MULTIOBJECT values truncate at 3 items with a bare `…`.
1212
- Add a `.count()` call on the queryset before slicing, or
1313
- Fetch all items into a list and slice in Python (simpler, acceptable for typical M2M sizes).
1414
- Pass the full count alongside the truncated list in the row tuple, then use it in the template.
15+
- Now the 3dots are not visible enought. Maybve a number of assigned object with some link to filter these objects would be suitable?
16+
- When 3dots are visible, will the q (serach) still find object which are not displayed? (not displayed is does seem bad to me)
17+
18+
---
19+
20+
## Add Updated Screenshot

netbox_custom_objects_tab/__init__.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,28 @@ class NetBoxCustomObjectsTabConfig(PluginConfig):
55
name = "netbox_custom_objects_tab"
66
verbose_name = "Custom Objects Tab"
77
description = 'Adds a "Custom Objects" tab to NetBox object detail pages'
8-
version = "1.0.1"
8+
version = "2.0.1"
99
author = "Jan Krupa"
1010
author_email = "jan.krupa@cesnet.cz"
1111
base_url = "custom-objects-tab"
1212
min_version = "4.5.0"
1313
max_version = "4.5.99"
1414
default_settings = {
15-
# app_label.model_name strings, or app_label.* to include all models in an app.
16-
"models": [
15+
# Per-type tabs: each Custom Object Type gets its own tab (opt-in, empty by default).
16+
"typed_models": [],
17+
# Combined tab: single "Custom Objects" tab showing all types (current behavior).
18+
"combined_models": [
1719
"dcim.*",
1820
"ipam.*",
1921
"virtualization.*",
2022
"tenancy.*",
21-
"contacts.*",
2223
],
23-
# Label shown on the tab; override in PLUGINS_CONFIG.
24-
"label": "Custom Objects",
25-
# Tab sort weight; lower values appear further left.
26-
"weight": 2000,
24+
# Label shown on the combined tab; override in PLUGINS_CONFIG.
25+
"combined_label": "Custom Objects",
26+
# Tab sort weight for the combined tab.
27+
"combined_weight": 2000,
28+
# Tab sort weight for all typed tabs.
29+
"typed_weight": 2100,
2730
}
2831

2932
def ready(self):

netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab.html renamed to netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ <h2 class="mb-0">{% trans "Custom Objects" %}</h2>
8282
</div>
8383

8484
{# --- table zone (swapped by HTMX) --- #}
85-
{% include 'netbox_custom_objects_tab/custom_objects_tab_partial.html' %}
85+
{% include 'netbox_custom_objects_tab/combined/tab_partial.html' %}
8686

8787
</div>
8888
</div>

netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab_partial.html renamed to netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html

File renamed without changes.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
{% extends base_template %}
2+
{% load helpers %}
3+
{% load buttons %}
4+
{% load render_table from django_tables2 %}
5+
{% load i18n %}
6+
7+
{% block content %}
8+
9+
{% if table %}
10+
<hr class="mt-0 mb-3">
11+
{# Results / Filters inner tabs #}
12+
<ul class="nav nav-tabs custom-objects-subtabs mb-3" role="tablist">
13+
<li class="nav-item" role="presentation">
14+
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="object-list" aria-selected="true">
15+
{% trans "Results" %}
16+
<span class="badge text-bg-secondary total-object-count">{{ table.page.paginator.count }}</span>
17+
</a>
18+
</li>
19+
{% if filter_form %}
20+
<li class="nav-item" role="presentation">
21+
<button class="nav-link" id="filters-form-tab" data-bs-toggle="tab" data-bs-target="#filters-form" type="button" role="tab" aria-controls="filters-form" aria-selected="false">
22+
{% trans "Filters" %}
23+
{% if filter_form %}{% badge filter_form.changed_data|length bg_color="primary" %}{% endif %}
24+
</button>
25+
</li>
26+
{% endif %}
27+
</ul>
28+
29+
{# Results tab pane #}
30+
<div class="tab-pane show active" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
31+
32+
{# Applied filters #}
33+
{% if filter_form %}
34+
{% applied_filters model filter_form request.GET %}
35+
{% endif %}
36+
37+
{# Table controls: quick search + configure table #}
38+
{% include 'inc/table_controls_htmx.html' with table_modal=table.name|add:"_config" %}
39+
40+
<form method="post" class="form form-horizontal"
41+
action="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}">
42+
{% csrf_token %}
43+
44+
{# Select all (multi-page) #}
45+
{% if table.paginator.num_pages > 1 %}
46+
<div id="select-all-box" class="d-none card d-print-none">
47+
<div class="form col-md-12">
48+
<div class="card-body d-flex justify-content-between">
49+
<div class="form-check">
50+
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
51+
<label for="select-all" class="form-check-label">
52+
{% blocktrans trimmed with count=table.page.paginator.count %}
53+
Select <strong>all <span class="total-object-count">{{ count }}</span></strong> matching query
54+
{% endblocktrans %}
55+
</label>
56+
</div>
57+
<div class="bulk-action-buttons">
58+
<button type="submit" name="_edit" formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}" class="btn btn-yellow">
59+
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
60+
</button>
61+
<button type="submit" name="_delete" formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}" class="btn btn-red">
62+
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
63+
</button>
64+
</div>
65+
</div>
66+
</div>
67+
</div>
68+
{% endif %}
69+
70+
<div class="form form-horizontal">
71+
{% csrf_token %}
72+
<input type="hidden" name="return_url" value="{{ return_url }}" />
73+
74+
{# Objects table #}
75+
<div class="card">
76+
<div class="htmx-container table-responsive" id="object_list">
77+
{% include 'htmx/table.html' %}
78+
</div>
79+
</div>
80+
81+
{# Bulk action buttons #}
82+
<div class="btn-list d-print-none">
83+
<div class="bulk-action-buttons">
84+
<button type="submit" name="_edit" formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}" class="btn btn-yellow">
85+
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
86+
</button>
87+
<button type="submit" name="_delete" formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}" class="btn btn-red">
88+
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
89+
</button>
90+
</div>
91+
</div>
92+
</div>
93+
</form>
94+
</div>
95+
96+
{# Filters tab pane #}
97+
{% if filter_form %}
98+
<div class="tab-pane show" id="filters-form" role="tabpanel" aria-labelledby="filters-form-tab">
99+
{% include 'inc/filter_list.html' %}
100+
</div>
101+
{% endif %}
102+
103+
{% else %}
104+
<div class="card">
105+
<div class="card-body text-muted">
106+
{% trans "No custom objects are linked to this object." %}
107+
</div>
108+
</div>
109+
{% endif %}
110+
111+
{% endblock content %}
112+
113+
{% block modals %}
114+
{% if table %}
115+
{% table_config_form table %}
116+
{% endif %}
117+
{% endblock modals %}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import logging
2+
3+
from django.apps import apps
4+
from netbox.plugins import get_plugin_config
5+
6+
from .combined import register_combined_tabs
7+
from .typed import register_typed_tabs
8+
9+
logger = logging.getLogger("netbox_custom_objects_tab")
10+
11+
12+
def _resolve_model_labels(labels):
13+
"""
14+
Resolve a list of model label strings (e.g. ["dcim.*", "ipam.device"])
15+
into a deduplicated list of Django model classes.
16+
"""
17+
seen = set()
18+
result = []
19+
for label in labels:
20+
label = label.lower()
21+
if label.endswith(".*"):
22+
app_label = label[:-2]
23+
try:
24+
model_classes = list(apps.get_app_config(app_label).get_models())
25+
except LookupError:
26+
logger.warning(
27+
"netbox_custom_objects_tab: could not find app %r — skipping",
28+
app_label,
29+
)
30+
continue
31+
else:
32+
try:
33+
app_label, model_name = label.split(".", 1)
34+
model_classes = [apps.get_model(app_label, model_name)]
35+
except (ValueError, LookupError):
36+
logger.warning(
37+
"netbox_custom_objects_tab: could not find model %r — skipping",
38+
label,
39+
)
40+
continue
41+
42+
for model_class in model_classes:
43+
key = (model_class._meta.app_label, model_class._meta.model_name)
44+
if key not in seen:
45+
seen.add(key)
46+
result.append(model_class)
47+
48+
return result
49+
50+
51+
def register_tabs():
52+
"""
53+
Read plugin config and register both combined and typed tabs.
54+
Called from AppConfig.ready().
55+
"""
56+
try:
57+
combined_labels = get_plugin_config("netbox_custom_objects_tab", "combined_models")
58+
combined_label = get_plugin_config("netbox_custom_objects_tab", "combined_label")
59+
combined_weight = get_plugin_config("netbox_custom_objects_tab", "combined_weight")
60+
typed_labels = get_plugin_config("netbox_custom_objects_tab", "typed_models")
61+
typed_weight = get_plugin_config("netbox_custom_objects_tab", "typed_weight")
62+
except Exception:
63+
logger.exception("Could not read netbox_custom_objects_tab plugin config")
64+
return
65+
66+
if combined_labels:
67+
combined_models = _resolve_model_labels(combined_labels)
68+
register_combined_tabs(combined_models, combined_label, combined_weight)
69+
70+
if typed_labels:
71+
typed_models = _resolve_model_labels(typed_labels)
72+
register_typed_tabs(typed_models, typed_weight)

0 commit comments

Comments
 (0)