Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
aacb935
Vendor netbox-custom-objects-tab into related_tabs/
Kani999 May 26, 2026
7f439e4
Wire related_tabs into ready(); auto-discover targets from COT fields
Kani999 May 26, 2026
ee2f74b
Add CustomObjectType.show_dedicated_tab opt-in for per-COT typed tabs
Kani999 May 26, 2026
3f7bf06
Fix NoReverseMatch on COT list view: call super().ready() before regi…
Kani999 May 26, 2026
6c9f948
Add multi-worker tab-registry hot-reload (signals + middleware + Redis)
Kani999 May 26, 2026
13470f6
Fix typed-tab hot-reload: re-inject URLs into host apps' captured url…
Kani999 May 26, 2026
2c09579
Hot-reload: defer refresh to on_commit + listen to m2m_changed on rel…
Kani999 May 26, 2026
1e106e5
Hot-reload defence-in-depth: ViewTab.visible() + Redis-key seed on st…
Kani999 May 26, 2026
8994127
Add unit tests for related_tabs registry, signals, visible(), refresh
Kani999 May 26, 2026
e9c1f8e
Document Related-Object Tabs in README
Kani999 May 26, 2026
f9f67ea
Use verbose_name_plural for typed-tab label
Kani999 May 26, 2026
219bde8
Fix vacuous dispatch_uid test + missing clear_url_caches() on startup
Kani999 May 27, 2026
737535a
Make hot-reload wording WSGI-neutral, not gunicorn-specific
Kani999 May 27, 2026
05e8a88
Fix related-tabs correctness, permissions, and hot-reload edge cases
Kani999 May 27, 2026
d10ce9c
Require login on related-object tab views
Kani999 May 27, 2026
770c155
related-tabs: hot-reload via DB cache_timestamp token, not Redis
Kani999 May 29, 2026
06829e2
related-tabs: consolidate shared view helpers (no behaviour change)
Kani999 May 29, 2026
c50f953
related-tabs: unify combined/typed reference-query logic (A1)
Kani999 May 29, 2026
002f25f
related-tabs: reuse build_filterset_form_class in typed tab (A2)
Kani999 May 29, 2026
3eaacbf
related-tabs: extract shared custom-object table builder (A3)
Kani999 May 29, 2026
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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,64 @@ PLUGINS_CONFIG = {
}
```

## Related-Object Tabs

Whenever a Custom Object Type has an Object or Multi-object field that points
at another NetBox object (e.g. `dcim.device`, `ipam.prefix`, `tenancy.tenant`),
the plugin automatically renders a **Custom Objects** tab on that target
object's detail page. The tab lists every custom object linking back to the
parent — with search, filters, pagination, column configuration, and per-row
edit/delete actions — so you can see what's connected without leaving the
object you're already looking at.

### Combined tab (always on)

The combined "Custom Objects" tab appears on the detail page of any NetBox
object referenced by at least one custom-object field. It aggregates all
related custom objects across every Custom Object Type that points at this
host, with a Type column to disambiguate. No configuration required — the tab
discovery runs automatically from the Custom Object Type Field definitions.

### Dedicated tab per Custom Object Type (opt-in)

For Custom Object Types where the aggregated view isn't enough, tick **Show
dedicated tab** on the Custom Object Type's edit form. The plugin then
registers a per-type tab on every detail page the COT references, with the
COT's full native list-view experience: type-specific columns, sidebar
filters, bulk edit/delete, and a pre-filled "Add" button. The flag is also
available from bulk edit, CSV import, and the REST API
(`PATCH /api/plugins/custom-objects/custom-object-types/<id>/`
`{"show_dedicated_tab": true}`).

### Hot-reload (no restart required)

Toggling **Show dedicated tab**, creating or deleting a Custom Object Type,
or editing a polymorphic field's allowed target types takes effect on the
next page load without restarting NetBox or its WSGI server. Cross-process
propagation uses a Redis-shared monotonic counter
(`nbco:tab_registry_version`) plus a thin middleware
(`TabRegistryRefreshMiddleware`) that NetBox auto-installs via the plugin
config. Cost on the steady-state hot path: one Redis GET per request.

> [!NOTE]
> The middleware is registered automatically through the plugin's
> `PluginConfig.middleware` list — no manual addition to
> `MIDDLEWARE` in `configuration.py` is required.

### Behavioural notes

* A dedicated tab with zero linked custom objects is **hidden by default**
(`hide_if_empty=True` on the underlying `ViewTab`). If you enable
`show_dedicated_tab` on a COT before any instances reference the host
model, the tab will materialise the moment the first instance is
created.
* The combined-tab badge count reflects the total before per-COT view
permissions are applied. A user without permission to view a particular
COT will see the inflated count but no leaked rows once they open the
tab. (Acknowledged limitation; tracked for a follow-up PR.)
* Renaming a Custom Object Type's slug propagates correctly to both the
registry and host apps' URL configurations on the next request.

## Known Limitations

NetBox Custom Objects is now Generally Available which means you can use it in production and migrations to future versions will work. There are many upcoming features including GraphQL support - the best place to see what's on the way is the [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) list on the GitHub repository.
93 changes: 92 additions & 1 deletion netbox_custom_objects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,19 @@ class CustomObjectsPluginConfig(PluginConfig):
required_settings = []
template_extensions = "template_content.template_extensions"

# NetBox appends each plugin's middleware list to the global MIDDLEWARE
# setting at startup (see netbox/settings.py around the
# plugin_config.middleware line). This middleware reads the
# (MAX(cache_timestamp), COUNT(*)) snapshot over CustomObjectType on
# every request and refreshes our local registry when another process
# has mutated state, so CustomObjectType / CustomObjectTypeField changes
# (including show_dedicated_tab toggles and polymorphic field target
# mutations) propagate across WSGI worker processes without requiring
# a NetBox restart. No Redis or other shared cache backend is involved.
middleware = [
"netbox_custom_objects.related_tabs.middleware.TabRegistryRefreshMiddleware",
]

@staticmethod
def should_skip_dynamic_model_creation():
"""
Expand Down Expand Up @@ -234,6 +247,33 @@ def ready(self):
# Patch ObjectSelectorView to support dynamically-generated custom object models
_patch_object_selector_view()

# Wire related_tabs hot-reload signals unconditionally.
#
# The signal handlers only reference the static models
# (CustomObjectType / CustomObjectTypeField), so they are safe to
# connect regardless of the dynamic-model lifecycle. Connecting here
# — before any of the should_skip_dynamic_model_creation() branches —
# means signal-driven tests work without sniffing sys.argv, and
# production hot-reload is wired in one consistent place across every
# startup branch (normal, test, migrate, makemigrations, collectstatic,
# mid-migration recovery).
#
# Safety during migrate: handlers schedule force_local_refresh via
# ``transaction.on_commit``, so a rolled-back save can't leak a
# registry mutation. ``_do_refresh`` -> ``register_tabs`` ->
# ``_discover_target_content_type_ids`` already swallows
# OperationalError / ProgrammingError during migrations. Worst case
# if a data migration saves a COT: one wasted refresh + a "database
# unavailable" warning log. No crash, no schema corruption.
try:
from netbox_custom_objects.related_tabs.signals import connect as connect_related_tabs_signals
connect_related_tabs_signals()
except Exception:
import logging # noqa: PLC0415
logging.getLogger(__name__).exception(
"related_tabs.signals.connect() failed; hot-reload disabled, restart required after COT changes"
)

# Suppress warnings about database calls during app initialization
with warnings.catch_warnings():
warnings.filterwarnings(
Expand All @@ -243,7 +283,9 @@ def ready(self):
"ignore", category=UserWarning, message=".*database.*"
)

# Skip database calls if dynamic models can't be created yet
# Skip database calls if dynamic models can't be created yet.
# related_tabs signals were already connected above
# (unconditionally), so no per-skip-path wiring is needed.
if self.should_skip_dynamic_model_creation():
super().ready()
return
Expand Down Expand Up @@ -296,8 +338,57 @@ def ready(self):
from django.apps import apps as django_apps
django_apps.clear_cache()

# super().ready() is PluginConfig.ready(), which calls
# netbox.models.features.register_models(*self.get_models()) — that's
# what adds the changelog/journal/jobs/etc. view entries to
# registry['views'] for every NetBoxModel subclass in this plugin.
# Our register_tabs() below indirectly triggers
# netbox_custom_objects/urls.py to load (via _inject_co_urls()), and
# urls.py snapshots registry['views'] via get_model_urls() at import
# time. If we ran register_tabs() before super().ready(), urls.py
# would be loaded with an incomplete registry and the resulting
# urlpatterns would be missing changelog/journal/etc. — breaking
# reverse() for every NetBoxModel feature URL on CustomObjectType.
super().ready()

try:
from django.urls import clear_url_caches
from netbox_custom_objects.related_tabs.registry import register_tabs
register_tabs()
# register_tabs() mutates netbox_custom_objects.urls.urlpatterns
# via _inject_co_urls(). Drop any URL resolver caches that
# super().ready() / other plugins' ready() may have built so
# reverse() resolves against the patched patterns. Mirrors the
# clear_url_caches() call _do_refresh() makes after every
# hot-reload.
clear_url_caches()
except Exception:
import logging # noqa: PLC0415
logging.getLogger(__name__).exception(
"related_tabs.register_tabs() failed; continuing without tabs"
)

# Snapshot the current cache_timestamp / count token after the
# initial register_tabs() so the first request served by this
# process doesn't trigger a redundant full refresh in
# TabRegistryRefreshMiddleware. Without this, _last_seen_state
# is None and the first comparison always misses. Signals were
# connected at the top of ready() (unconditionally), so any
# concurrent COT save between register_tabs() and seed_local_state()
# — e.g. from another plugin's ready() — is observed by the signal
# handler and force_local_refresh updates the snapshot rather than
# leaving us with a state we'll never know diverged. Failures here
# are logged and swallowed — a worst-case extra refresh on the
# next request is harmless.
try:
from netbox_custom_objects.related_tabs import seed_local_state
seed_local_state()
except Exception:
import logging # noqa: PLC0415
logging.getLogger(__name__).exception(
"related_tabs.seed_local_state() failed; first request will trigger an extra refresh"
)

def get_model(self, model_name, require_ready=True):
self.apps.check_apps_ready()
try:
Expand Down
1 change: 1 addition & 0 deletions netbox_custom_objects/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ class Meta:
"slug",
"version",
"group_name",
"show_dedicated_tab",
"description",
"tags",
"created",
Expand Down
17 changes: 14 additions & 3 deletions netbox_custom_objects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DynamicModelChoiceField, SlugField, TagFilterField)
from utilities.forms.rendering import FieldSet
from utilities.forms.utils import get_field_value
from utilities.forms.widgets import BulkEditNullBooleanSelect
from utilities.object_types import object_type_name

from netbox_custom_objects.choices import SearchWeightChoices
Expand Down Expand Up @@ -59,7 +60,7 @@ class CustomObjectTypeForm(NetBoxModelForm):
fieldsets = (
FieldSet(
"name", "verbose_name", "verbose_name_plural", "slug",
"version", "description", "group_name", "tags",
"version", "description", "group_name", "show_dedicated_tab", "tags",
),
)
comments = CommentField()
Expand All @@ -68,18 +69,27 @@ class Meta:
model = CustomObjectType
fields = (
"name", "verbose_name", "verbose_name_plural", "slug", "version", "description",
"group_name", "comments", "tags",
"group_name", "show_dedicated_tab", "comments", "tags",
)


class CustomObjectTypeBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_("Description"), max_length=200, required=False
)
# NullBooleanField + BulkEditNullBooleanSelect is required for tri-state bulk-edit
# of a non-nullable BooleanField: unset (no change) / True / False. A plain
# BooleanField would only submit True or, when unchecked, omit the value
# entirely, making it impossible to bulk-set show_dedicated_tab=False.
show_dedicated_tab = forms.NullBooleanField(
label=_("Dedicated tab"),
required=False,
widget=BulkEditNullBooleanSelect(),
)
comments = CommentField()

model = CustomObjectType
fieldsets = (FieldSet("description"),)
fieldsets = (FieldSet("description", "show_dedicated_tab"),)
nullable_fields = (
"description",
"comments",
Expand All @@ -95,6 +105,7 @@ class Meta:
"slug",
"description",
"comments",
"show_dedicated_tab",
"tags",
)

Expand Down
17 changes: 17 additions & 0 deletions netbox_custom_objects/migrations/0015_show_dedicated_tab.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 6.0.4 on 2026-05-26 05:18

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
('netbox_custom_objects', '0014_fix_mixed_case_field_names'),
]

operations = [
migrations.AddField(
model_name='customobjecttype',
name='show_dedicated_tab',
field=models.BooleanField(default=False),
),
]
75 changes: 73 additions & 2 deletions netbox_custom_objects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,14 @@ class CustomObjectType(NetBoxModel):
blank=True,
help_text=_("Used to group similar custom object types in the navigation menu")
)
show_dedicated_tab = models.BooleanField(
default=False,
help_text=_(
"If enabled, render a per-type tab on detail pages of every NetBox object this "
"Custom Object Type references. Otherwise, references are aggregated under a "
"single combined \"Custom Objects\" tab."
),
)
schema_document = models.JSONField(
blank=True,
null=True,
Expand Down Expand Up @@ -2429,15 +2437,20 @@ def clear_cache_on_custom_object_type_save(sender, instance, **kwargs):


@receiver(m2m_changed, sender=CustomObjectTypeField.related_object_types.through)
def check_polymorphic_recursion(sender, instance, action, pk_set, **kwargs):
def check_polymorphic_recursion(sender, instance, action, pk_set, reverse, **kwargs):
"""
Prevent circular references in polymorphic field allowed-type lists.

clean() cannot check this because related_object_types is a M2M that is set
after the instance is saved. m2m_changed fires on pre_add, which lets us abort
the operation before any rows are written.

Reverse-side mutations (``object_type.polymorphic_custom_object_type_fields.add(...)``)
fire the same signal with ``instance`` being an ``ObjectType`` rather than a
``CustomObjectTypeField``; this receiver's invariants don't apply there and
the ObjectType has no ``custom_object_type`` attribute, so bail out early.
"""
if action != "pre_add" or not pk_set:
if reverse or action != "pre_add" or not pk_set:
return

own_object_type_id = instance.custom_object_type.object_type_id
Expand All @@ -2460,6 +2473,64 @@ def check_polymorphic_recursion(sender, instance, action, pk_set, **kwargs):
)


@receiver(m2m_changed, sender=CustomObjectTypeField.related_object_types.through)
def bump_cot_cache_timestamp_on_m2m_change(sender, instance, action, reverse, **kwargs):
"""
Bump the parent COT's cache_timestamp when a polymorphic field's allowed-type
M2M (``related_object_types``) changes outside the normal ``field.save()`` path.

Defence-in-depth: the UI form disables this M2M for existing field instances
(``forms.py``) and ``CustomObjectTypeFieldSerializer.validate()`` rejects
changes via the REST API (``api/serializers.py``). This receiver covers
direct mutation paths that bypass ``field.save()`` — Django shell sessions,
ad-hoc scripts, or migration data fixups that call ``.add()`` / ``.remove()``
/ ``.set()`` on the M2M descriptor without re-saving the parent field.
Without this bump, peer workers would not observe the M2M target change
because ``cache_timestamp`` would not have advanced; the model cache, the
related-tabs registry, and any other consumer of the cache_timestamp
invariant would all silently desynchronise.

Forward direction (``instance`` is a ``CustomObjectTypeField``,
``reverse=False``): bump the parent COT's ``cache_timestamp`` directly.

Reverse direction (``instance`` is an ``ObjectType``, ``reverse=True``):
the affected fields are in ``pk_set`` for post_add / post_remove; iterate
them and bump each field's parent COT. For post_clear ``pk_set`` is
None — the through rows are already gone so we can't recover which fields
were affected — log a warning so operators know to restart workers or
trigger a refresh via a manual COT save. Django does NOT mirror the
forward signal on reverse-side calls, so this branch is the only chance
to invalidate.

Initial field creation through the API does NOT depend on this receiver:
``field.save()`` bumps ``cache_timestamp`` first, and the subsequent M2M
write is wrapped in the same ``transaction.atomic()`` by the serializer's
``create()`` so peer workers observe both atomically at commit time.
"""
if action not in {'post_add', 'post_remove', 'post_clear'}:
return
if not reverse:
instance.custom_object_type.save(update_fields=['cache_timestamp'])
return
pks = kwargs.get('pk_set') or ()
if not pks:
# post_clear in reverse — through rows already gone, pk_set unavailable.
logger.warning(
'related_object_types %s observed from reverse direction; '
'cache_timestamp not bumped (no pk_set). '
'Save any CustomObjectType to recover cross-worker sync.',
action,
)
return
affected_cot_ids = set(
CustomObjectTypeField.objects.filter(pk__in=pks).values_list(
'custom_object_type_id', flat=True
)
)
for cot in CustomObjectType.objects.filter(pk__in=affected_cot_ids):
cot.save(update_fields=['cache_timestamp'])


@receiver(post_save, sender=CustomObjectTypeField)
def clear_cache_on_field_save(sender, instance, **kwargs):
"""
Expand Down
Loading