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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ db.sqlite3
*.swo

tools/

ignore/
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ 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.0.1] - 2026-02-25

### Added

- **Typed tabs (per-type)** — each Custom Object Type gets its own tab with a full-featured
list view: type-specific columns, filterset sidebar, bulk edit/delete, configure table,
and HTMX pagination.
- `typed_models` and `typed_weight` config settings.
- Third-party plugin model support for both tab modes.

### Changed

- Renamed `models` config to `combined_models`; `label` to `combined_label`; `weight` to
`combined_weight`.
- Refactored views from single `views.py` to `views/` package (`__init__.py`, `combined.py`,
`typed.py`).
- Templates reorganized into `combined/` and `typed/` subdirectories.

### Fixed

- Handle missing database during startup — `register_typed_tabs()` now catches
`OperationalError` and `ProgrammingError` so NetBox can start even when the database
is unavailable or migrations haven't run yet.
- Bulk action return URL in typed tabs — uses query parameter `?return_url=` on `formaction`
for reliable redirect.

## [1.0.1] - 2026-02-24

### Fixed
Expand Down
260 changes: 118 additions & 142 deletions CLAUDE.md

Large diffs are not rendered by default.

63 changes: 39 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
[![NetBox](https://img.shields.io/badge/NetBox-4.5.x-blue)](https://github.com/netbox-community/netbox)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)

A NetBox 4.5.x plugin that adds a **Custom Objects** tab to standard object detail pages,
showing any Custom Object instances from the `netbox_custom_objects` plugin that reference
A NetBox 4.5.x plugin that adds **Custom Objects** tabs to standard object detail pages,
showing Custom Object instances from the `netbox_custom_objects` plugin that reference
those objects via OBJECT or MULTIOBJECT fields.

The tab includes **pagination**, **text search**, **column sorting**, **type filtering**,
and **tag filtering**, with HTMX-powered partial updates so table interactions don't reload
the full page.
Two tab modes are available:

- **Combined tab** — a single tab showing all Custom Object Types in one table, with
pagination, text search, column sorting, type/tag filtering, and HTMX partial updates.
- **Typed tabs** — each Custom Object Type gets its own tab with a full-featured list view
(type-specific columns, filterset sidebar, bulk actions, configure table) matching the
native Custom Objects list page.

## Screenshot

Expand All @@ -21,13 +25,14 @@ the full page.
## Requirements

- NetBox 4.5.0 – 4.5.99
- `netbox_custom_objects` plugin installed and configured
- `netbox_custom_objects` plugin **≥ 0.4.6** installed and configured

## Compatibility

| Plugin version | NetBox version |
|----------------|----------------|
| 1.0.x | 4.5.x |
| Plugin version | NetBox version | `netbox_custom_objects` version |
|----------------|----------------|---------------------------------|
| 2.0.x | 4.5.x | ≥ 0.4.6 |
| 1.0.x | 4.5.x | ≥ 0.4.4 |

## Installation

Expand All @@ -47,9 +52,11 @@ PLUGINS = [
# Optional — defaults shown below
PLUGINS_CONFIG = {
'netbox_custom_objects_tab': {
'models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*'],
'label': 'Custom Objects',
'weight': 2000,
'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'],
'combined_label': 'Custom Objects',
'combined_weight': 2000,
'typed_models': [], # opt-in: e.g. ['dcim.*']
'typed_weight': 2100,
}
}
```
Expand All @@ -58,26 +65,34 @@ Restart NetBox. No database migrations required.

## Configuration

| Setting | Default | Description |
|----------|---------|-------------|
| `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. |
| `label` | `'Custom Objects'` | Text displayed on the tab. |
| `weight` | `2000` | Controls tab position in the tab bar; lower values appear further left. |
| Setting | Default | Description |
|---------|---------|-------------|
| `combined_models` | `['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*']` | Models that get the combined "Custom Objects" tab. Accepts `app_label.model_name` or `app_label.*` wildcards. |
| `combined_label` | `'Custom Objects'` | Text displayed on the combined tab. |
| `combined_weight` | `2000` | Tab position for the combined tab; lower = further left. |
| `typed_models` | `[]` | Models that get per-type tabs (opt-in, empty by default). Same format as `combined_models`. |
| `typed_weight` | `2100` | Tab position for all typed tabs. |

A model can appear in both `combined_models` and `typed_models` to get both tab styles.

### Examples

```python
# Default — all common NetBox apps
'models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*']
# Combined tab only (default)
'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*']

# Only specific models
'models': ['dcim.device', 'dcim.site', 'ipam.prefix']
# Per-type tabs for dcim models
'typed_models': ['dcim.*']

# Mix wildcards and specifics
'models': ['dcim.*', 'virtualization.*', 'ipam.ipaddress']
# Both modes for dcim, combined only for others
'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'],
'typed_models': ['dcim.*'],

# Only specific models
'combined_models': ['dcim.device', 'dcim.site', 'ipam.prefix']

# Third-party plugin models work identically
'models': ['dcim.*', 'ipam.*', 'inventory_monitor.*']
'combined_models': ['dcim.*', 'ipam.*', 'inventory_monitor.*']
```

Third-party plugin models are fully supported — Django treats plugin apps and built-in apps
Expand Down
6 changes: 6 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ Currently MULTIOBJECT values truncate at 3 items with a bare `…`.
- Add a `.count()` call on the queryset before slicing, or
- Fetch all items into a list and slice in Python (simpler, acceptable for typical M2M sizes).
- Pass the full count alongside the truncated list in the row tuple, then use it in the template.
- Now the 3dots are not visible enought. Maybve a number of assigned object with some link to filter these objects would be suitable?
- When 3dots are visible, will the q (serach) still find object which are not displayed? (not displayed is does seem bad to me)

---

## Add Updated Screenshot
19 changes: 11 additions & 8 deletions netbox_custom_objects_tab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,28 @@ class NetBoxCustomObjectsTabConfig(PluginConfig):
name = "netbox_custom_objects_tab"
verbose_name = "Custom Objects Tab"
description = 'Adds a "Custom Objects" tab to NetBox object detail pages'
version = "1.0.1"
version = "2.0.1"
author = "Jan Krupa"
author_email = "jan.krupa@cesnet.cz"
base_url = "custom-objects-tab"
min_version = "4.5.0"
max_version = "4.5.99"
default_settings = {
# app_label.model_name strings, or app_label.* to include all models in an app.
"models": [
# Per-type tabs: each Custom Object Type gets its own tab (opt-in, empty by default).
"typed_models": [],
# Combined tab: single "Custom Objects" tab showing all types (current behavior).
"combined_models": [
"dcim.*",
"ipam.*",
"virtualization.*",
"tenancy.*",
"contacts.*",
],
# Label shown on the tab; override in PLUGINS_CONFIG.
"label": "Custom Objects",
# Tab sort weight; lower values appear further left.
"weight": 2000,
# Label shown on the combined tab; override in PLUGINS_CONFIG.
"combined_label": "Custom Objects",
# Tab sort weight for the combined tab.
"combined_weight": 2000,
# Tab sort weight for all typed tabs.
"typed_weight": 2100,
}

def ready(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ <h2 class="mb-0">{% trans "Custom Objects" %}</h2>
</div>

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

</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
{% extends base_template %}
{% load helpers %}
{% load buttons %}
{% load render_table from django_tables2 %}
{% load i18n %}

{% block content %}

{% if table %}
<hr class="mt-0 mb-3">
{# Results / Filters inner tabs #}
<ul class="nav nav-tabs custom-objects-subtabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<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">
{% trans "Results" %}
<span class="badge text-bg-secondary total-object-count">{{ table.page.paginator.count }}</span>
</a>
</li>
{% if filter_form %}
<li class="nav-item" role="presentation">
<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">
{% trans "Filters" %}
{% if filter_form %}{% badge filter_form.changed_data|length bg_color="primary" %}{% endif %}
</button>
</li>
{% endif %}
</ul>

{# Results tab pane #}
<div class="tab-pane show active" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">

{# Applied filters #}
{% if filter_form %}
{% applied_filters model filter_form request.GET %}
{% endif %}

{# Table controls: quick search + configure table #}
{% include 'inc/table_controls_htmx.html' with table_modal=table.name|add:"_config" %}

<form method="post" class="form form-horizontal"
action="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}">
{% csrf_token %}

{# Select all (multi-page) #}
{% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card d-print-none">
<div class="form col-md-12">
<div class="card-body d-flex justify-content-between">
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
{% blocktrans trimmed with count=table.page.paginator.count %}
Select <strong>all <span class="total-object-count">{{ count }}</span></strong> matching query
{% endblocktrans %}
</label>
</div>
<div class="bulk-action-buttons">
<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">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
<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">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
</div>
</div>
</div>
</div>
{% endif %}

<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{{ return_url }}" />

{# Objects table #}
<div class="card">
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>

{# Bulk action buttons #}
<div class="btn-list d-print-none">
<div class="bulk-action-buttons">
<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">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
<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">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
</div>
</div>
</div>
</form>
</div>

{# Filters tab pane #}
{% if filter_form %}
<div class="tab-pane show" id="filters-form" role="tabpanel" aria-labelledby="filters-form-tab">
{% include 'inc/filter_list.html' %}
</div>
{% endif %}

{% else %}
<div class="card">
<div class="card-body text-muted">
{% trans "No custom objects are linked to this object." %}
</div>
</div>
{% endif %}

{% endblock content %}

{% block modals %}
{% if table %}
{% table_config_form table %}
{% endif %}
{% endblock modals %}
72 changes: 72 additions & 0 deletions netbox_custom_objects_tab/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import logging

from django.apps import apps
from netbox.plugins import get_plugin_config

from .combined import register_combined_tabs
from .typed import register_typed_tabs

logger = logging.getLogger("netbox_custom_objects_tab")


def _resolve_model_labels(labels):
"""
Resolve a list of model label strings (e.g. ["dcim.*", "ipam.device"])
into a deduplicated list of Django model classes.
"""
seen = set()
result = []
for label in labels:
label = label.lower()
if label.endswith(".*"):
app_label = label[:-2]
try:
model_classes = list(apps.get_app_config(app_label).get_models())
except LookupError:
logger.warning(
"netbox_custom_objects_tab: could not find app %r — skipping",
app_label,
)
continue
else:
try:
app_label, model_name = label.split(".", 1)
model_classes = [apps.get_model(app_label, model_name)]
except (ValueError, LookupError):
logger.warning(
"netbox_custom_objects_tab: could not find model %r — skipping",
label,
)
continue

for model_class in model_classes:
key = (model_class._meta.app_label, model_class._meta.model_name)
if key not in seen:
seen.add(key)
result.append(model_class)

return result


def register_tabs():
"""
Read plugin config and register both combined and typed tabs.
Called from AppConfig.ready().
"""
try:
combined_labels = get_plugin_config("netbox_custom_objects_tab", "combined_models")
combined_label = get_plugin_config("netbox_custom_objects_tab", "combined_label")
combined_weight = get_plugin_config("netbox_custom_objects_tab", "combined_weight")
typed_labels = get_plugin_config("netbox_custom_objects_tab", "typed_models")
typed_weight = get_plugin_config("netbox_custom_objects_tab", "typed_weight")
except Exception:
logger.exception("Could not read netbox_custom_objects_tab plugin config")
return

if combined_labels:
combined_models = _resolve_model_labels(combined_labels)
register_combined_tabs(combined_models, combined_label, combined_weight)

if typed_labels:
typed_models = _resolve_model_labels(typed_labels)
register_typed_tabs(typed_models, typed_weight)
Loading
Loading