Related object tabs#482
Conversation
fda8748 to
9d84499
Compare
|
Instead of a static list in PLUGINS_CONFIG, a per-COT "Show as dedicated tab" checkbox in the admin UI would be cleaner. High-priority COTs get their own tab; everything else falls back to a consolidated "Custom Objects" tab. |
|
@damsitt thanks for the suggestion — done. Replaced 12 commits, |
|
@Kani999 We're working on finalizing the v0.5.0 release of netbox-custom-objects this week. It already has a huge number of major features on-train, but it would be nice to get this one in there too. However, as it was not a stakeholder promise it isn't the end of the world if it has to be deferred to a v0.6.0. What is your feeling on the readiness? Is this week realistic? |
- __init__.py: call clear_url_caches() after inject_co_urls() in ready() so URL resolver picks up injected CO patterns in tests and management commands (flagged by CodeRabbit on PR netboxlabs#482) - combined_tab.html: render non-empty non-None values instead of always showing em-dash for non-URL/object fields in the else branch - combined_tab.html: replace plain edit button with a proper Bootstrap dropdown toggle so the action dropdown renders correctly - README.md: minor wording tweak ("Custom Object Type (COT)")
This comment was marked as outdated.
This comment was marked as outdated.
|
Hi @mcolemann — from my side it feels ready. I've just run through the full smoke test matrix above and all scenarios pass except permission gating (which I haven't had a chance to verify yet, but the underlying logic is standard NetBox — it should just work). One known limitation worth calling out before merge: if you rename a COT's slug while the server is running, the old dedicated tab continues to appear alongside the new one until the process is restarted. The tab registry picks up the new slug immediately (live toggle works correctly), but the old URL entry stays in the router until the next startup. It's documented in the smoke test table (row 10) and in a If you or the team can spin up the test data (script + JSON are attached above) and walk through a few scenarios, that would be the fastest path to confirming it's v0.5.0-ready. Happy to address any issues that come up during your review. |
Permission gating — observed behaviour (smoke test row 11)Tested with a user who has view: Device only (no permissions on any custom object type). Base panel (the "Custom Objects" linked-objects panel in the left-side panels area): "Custom Objects" combined tab (the tab injected at the top of the device detail page): Dedicated tabs: Summary:
Neither issue is introduced by this PR, but worth noting before v0.5.0 ships. |
4dadc29 to
7efcede
Compare
|
@Kani999 Thanks for pushing this forward. I think, in the interest of avoiding too much churn and destabilization, I'd like to defer this to a v0.6.0 release. (But note that doesn't mean it will be a long time before that release; it's just the next one to be cut from the The main thing I'm worried about is polymorphic object/multiobject fields, which are just about to land in |
7efcede to
2ee95e5
Compare
- __init__.py: call clear_url_caches() after inject_co_urls() in ready() so URL resolver picks up injected CO patterns in tests and management commands (flagged by CodeRabbit on PR netboxlabs#482) - combined_tab.html: render non-empty non-None values instead of always showing em-dash for non-URL/object fields in the else branch - combined_tab.html: replace plain edit button with a proper Bootstrap dropdown toggle so the action dropdown renders correctly - README.md: minor wording tweak ("Custom Object Type (COT)")
|
Understood, thanks for the context — agreed it's better to land related-object tabs on top of finalized polymorphic field support than to chase a moving target. I've already retargeted this PR from |
b0f88da to
d3faf82
Compare
|
Heads up — this is not ready to merge yet. I need to:
I'll push the revised commits and updated test results once that's done. |
|
@Kani999 I've converted this PR to a draft per your note above. Just mark it as "ready for review" when the time comes. Thanks! |
Drop the standalone CESNET tab plugin's view, template, and templatetag code
into netbox_custom_objects/related_tabs/ as a subpackage. This is pure file
placement plus bulk path renames — no behaviour change. Nothing is wired into
PluginConfig.ready() yet; that happens in a follow-up commit alongside the
show_dedicated_tab opt-in field and the multi-worker hot-reload.
Renames applied:
- template paths "netbox_custom_objects_tab/{combined,typed}/..."
-> "netbox_custom_objects/related_tabs/{combined,typed}/..."
- logger name "netbox_custom_objects_tab" -> "netbox_custom_objects.related_tabs"
- registry.py imports views/_co_common, views/combined, views/typed under the
new layout instead of flat siblings
registry.register_tabs() still reads PLUGINS_CONFIG knobs; the next commit
refactors it to auto-discover targets from CustomObjectTypeField rows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
d3faf82 to
9e0de1e
Compare
|
This branch has been completely reworked on top of upstream |
GET /api/plugins/custom-objects/custom-object-types/?slug=foo previously
returned every COT — django-filter silently dropped the unrecognised
query parameter because the filterset's Meta.fields didn't include slug.
Adding slug to the tuple lets django-filter auto-generate the full
lookup family (exact, __ic, __isw, __regex, ...) backed by the
CustomObjectType.slug SlugField.
Verified via manage.py shell:
from netbox_custom_objects.filtersets import CustomObjectTypeFilterSet
'slug' in CustomObjectTypeFilterSet.base_filters
True
Side observation from PR netboxlabs#482's polymorphic smoke run on 2026-05-26.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
40eb1f7 to
3234e2a
Compare
Refactor registry.register_tabs() to drop PLUGINS_CONFIG and instead
auto-discover target ContentTypes from CustomObjectTypeField rows (both
non-polymorphic .related_object_type and polymorphic .related_object_types).
Each unique target receives a combined "Custom Objects" tab; every COT also
gets a typed tab on those hosts. show_dedicated_tab gating lands in the next
commit.
Add a third pass to CustomObjectsPluginConfig.ready() that calls
register_tabs() after the existing two-pass model + serializer registration
and apps.clear_cache(). Wrapped in try/except so a failure here can't take
the plugin down on startup.
Patch netbox_custom_objects/templates/netbox_custom_objects/customobject.html
to load custom_object_tab_tags and call {% plugin_extra_tabs object %}
between the primary tab and Journal/Changelog. Custom-Object detail pages
now expose the auto-registered tabs the same way built-in NetBox detail
pages do.
Drop _resolve_dynamic_custom_object_models() and _resolve_model_labels();
auto-discovery replaces both. Combined-tab label is hardcoded "Custom
Objects" with the standalone-plugin default weights (2000 / 2100).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Combined "Custom Objects" tabs continue to register for every referenced
model regardless of this flag. Typed tabs (one per COT x host model) now
require show_dedicated_tab=True on the COT.
- show_dedicated_tab BooleanField (default=False) on CustomObjectType.
- Migration 0015_show_dedicated_tab depends on 0014_fix_mixed_case_field_names.
- Surface across the UI/API/CSV/bulk-edit/list/detail layers:
* CustomObjectTypeForm fieldsets + Meta.fields
* CustomObjectTypeBulkEditForm using NullBooleanField +
BulkEditNullBooleanSelect for tri-state bulk edit (a plain BooleanField
cannot bulk-clear)
* CustomObjectTypeImportForm Meta.fields
* CustomObjectTypeSerializer Meta.fields
* CustomObjectTypeTable column ('Dedicated tab', BooleanColumn)
* customobjecttype.html detail row using {% checkmark %} tag
- Gate typed-tab queryset in register_typed_tabs() on
custom_object_type__show_dedicated_tab=True.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ster_tabs()
The previous order caused customobjecttype_changelog / _journal / _jobs /
etc. URLs to be missing from netbox_custom_objects.urlpatterns, producing a
500 with "Reverse for 'customobjecttype_changelog' not found" on the
Custom Object Types list view as soon as any column tried to link to a
NetBoxModel feature URL.
Root cause: PluginConfig.ready() (the NetBox base class) calls
netbox.models.features.register_models(*self.get_models()), which is what
adds the changelog/journal/jobs/contacts/image-attachments/sync view
entries to registry['views'] for every NetBoxModel subclass in this
plugin.
Our register_tabs() indirectly triggers netbox_custom_objects/urls.py to
load via _inject_co_urls() (it does `import netbox_custom_objects.urls`).
urls.py snapshots registry['views'] via get_model_urls() at module-import
time. If register_tabs() ran before super().ready(), urls.py loaded with
an incomplete registry and the resulting urlpatterns were permanently
missing the feature URLs — reverse() on any of them failed.
Fix: invoke super().ready() before register_tabs() so register_models()
has populated registry['views'] by the time urls.py is loaded.
Verified locally via `manage.py shell`:
reverse('plugins:netbox_custom_objects:customobjecttype_changelog',
kwargs={'pk': 1})
now returns '/plugins/custom-objects/custom-object-types/1/changelog/'.
(customobjecttypefield_journal still fails — correct, since
CustomObjectTypeField is a ChangeLoggedModel, not a full NetBoxModel, so
it does not have a journal view.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Toggling CustomObjectType.show_dedicated_tab — or any other COT/COTField save/delete — no longer requires a NetBox restart. Each worker keeps its own local registry version; a Redis-shared counter (nbco:tab_registry_version) propagates changes between gunicorn workers. Components: - related_tabs/__init__.py: module-level _tab_registry_version + RLock; refresh_if_stale() (used by middleware on every request, fast path is one Redis GET) and force_local_refresh() (used by signal handlers, also bumps Redis so peers refresh next request). - related_tabs/registry.py: _purge_tab_entries() and _purge_injected_urls() remove our combined/typed entries by name prefix; _do_refresh() chains purge -> register_tabs() -> clear_url_caches(). register_tabs() is now safe to re-run. - related_tabs/signals.py: post_save and post_delete handlers on CustomObjectType and CustomObjectTypeField; each calls force_local_refresh(). dispatch_uid keys keep the handlers idempotent across autoreloader cycles. - related_tabs/middleware.py: TabRegistryRefreshMiddleware runs refresh_if_stale() before URL resolution. Auto-installed via CustomObjectsPluginConfig.middleware - NetBox extends MIDDLEWARE with each plugin's middleware list at startup (netbox/settings.py:977). - netbox_custom_objects/__init__.py: declares middleware on the PluginConfig and calls signals.connect() at the end of ready() (after register_tabs()). Verified end-to-end via manage.py shell: - _bump_remote_version() then refresh_if_stale() runs the refresh exactly once (returns True), second call is a no-op (False). - force_local_refresh() advances local and remote in lockstep. - post_save.send for CustomObjectType bumps _tab_registry_version 0 -> 1. - Two back-to-back refreshes produce identical registry state (no entries lost, injected URL list has exactly one entry per registration). - TabRegistryRefreshMiddleware is present in django.conf.settings.MIDDLEWARE. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…patterns The P4 hot-reload (commit da4de0b) refreshed the in-process tab registry correctly but missed a critical step: the URL patterns that get_model_urls(host_app, host_model) produced at startup are captured by include() into URLResolvers inside each host app's urls.py (e.g. dcim/urls.py line 87: path('devices/<int:pk>/', include(get_model_urls('dcim', 'device')))). That captured list is a one-shot snapshot of the registry. When _do_refresh() adds a new typed-tab entry to the registry, the captured list stays stale — so reverse() for the new URL fails and clicking the tab 404s, even though the registry has the entry and the combined-tab template tag renders correctly. Symptom from smoke-test report (commits aacb935..da4de0b): - Combined-tab badge hot-reloads correctly on instance create/delete. - PATCHing show_dedicated_tab=True on a COT and hard-reloading the host detail page produces no typed tab in the tab bar. - /dcim/devices/<pk>/custom-objects-<slug>/ returns 404. Fix: add _inject_host_typed_tab_urls() called from _do_refresh() after register_tabs() runs. For each host app that has at least one of our combined/typed tab entries (excluding netbox_custom_objects itself — that's handled by the existing _inject_co_urls()): 1. Import <app_label>.urls and find the URLResolver whose inner urlconf list contains the detail-view marker (an entry named exactly model_name — what get_model_urls produces for the detail view). 2. Call get_model_urls(app_label, model_name) afresh, which reads the current registry state and produces a list including our newly- registered typed tabs. 3. Replace the captured list's contents in place via captured[:] = fresh. Slice-assignment keeps the same list reference, so the URLResolver's @cached_property url_patterns continues to point at the right object. Verified end-to-end in manage.py shell: - Baseline: reverse('dcim:device_custom_objects_hotreload-targets', kwargs={'pk': 1}) → NoReverseMatch. - Insert fake registry entry; call _inject_host_typed_tab_urls(); clear_url_caches(). - Same reverse() → /dcim/devices/1/custom-objects-hotreload-targets/. - Existing dcim:device_custom_objects_maintenance-records and dcim:device_changelog continue to reverse correctly (no regression). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ated_object_types
Two robustness fixes to related_tabs/signals.py:
(P5b) Defer force_local_refresh() via transaction.on_commit so a
rolled-back transaction can't leak a Redis bump or in-process registry
mutation that doesn't reflect persisted state. Outside an atomic block,
on_commit runs the callable immediately, which matches the previous
behaviour for autocommit paths. Inside an atomic block that rolls back,
the callable never fires.
(P5h) Connect m2m_changed on CustomObjectTypeField.related_object_types
to catch polymorphic-target updates. Without this, the API serializer's
pattern of writing the M2M *after* the field's own save() left our
initial post_save refresh seeing an empty target list — newly-targeted
host ContentTypes weren't registered until something else nudged
another refresh (a later show_dedicated_tab toggle was the most common
trigger). Symptom from the 2026-05-26 polymorphic smoke run on
krupa.vm.cesnet.cz: after creating a poly field with
related_object_types=[device, tenant] and a CO instance bound to the
tenant, the tenant detail page's combined "Custom Objects" tab and
/tenancy/tenants/<pk>/custom-objects/ URL stayed absent across multiple
hard reloads. Only flipping show_dedicated_tab=true (which fires its
own post_save) made them appear.
Both refactors share a new _schedule_refresh(reason) helper to keep the
on_commit + try/except boilerplate in one place. The m2m_changed
handler narrows to action in {post_add, post_remove, post_clear} (the
state-has-actually-changed cases) and uses the same dispatch_uid
discipline for idempotency under the autoreloader.
Verified end-to-end in manage.py shell:
- Manual m2m_changed.send(...) bumps _tab_registry_version 0 -> 1.
- All five expected dispatch_uids are wired on post_save, post_delete,
and m2m_changed.
- ruff check + ruff format --check + manage.py check all clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…artup
(P5a) Add a ViewTab.visible() callable on each typed-tab view that re-reads
show_dedicated_tab from the DB per render. Defence-in-depth so a missed
hot-reload (or a worker that hasn't yet picked up a Redis bump) can't leave
a stale typed tab visible after the COT has been flipped back to
show_dedicated_tab=False. Costs one indexed-PK SELECT per visible tab
per render (values_list('show_dedicated_tab').get(pk=cot_pk)), negligible
compared with the badge query already running. Fails closed on
DoesNotExist (COT deleted, registry not yet refreshed) and on any other
DB error — better to hide a tab than 500 the detail page.
(P5c) Seed the Redis-shared tab-registry version key with cache.add()
after the initial register_tabs() runs in PluginConfig.ready(). Without
this, a Redis flush followed by a worker restart leaves the cluster in
"remote == 0 <= local == N" — all middleware refresh checks would see
remote not greater than local and skip refreshing, indefinitely.
cache.add is a no-op when the key already exists, so this is safe to
run on every startup.
Verified end-to-end in manage.py shell:
- tab.visible(<Device>) returns the live show_dedicated_tab value.
- After cache.delete(_REDIS_KEY); cache.add(_REDIS_KEY, 1, timeout=None)
the get_remote_version flips 0 -> 1.
- ruff check + ruff format --check + manage.py check all clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
18 tests across 7 test classes covering the surfaces most likely to regress under refactor: * DiscoveryTests — auto-discovery from both non-polymorphic related_object_type and polymorphic related_object_types M2M. * ResolveModelClassesTests — handles unknown / dedup / empty inputs. * IsOurTabNameTests — the name-prefix predicate used by purge. * PurgeRegistryTests — only our entries are removed; siblings stay. * ViewTabVisibleTests — tab.visible() returns the live show_dedicated_tab value; fails closed when the COT was deleted (no exception leaks). * SignalRefreshTests — post_save on COT, m2m_changed on related_object_types both bump _tab_registry_version via on_commit; dispatch_uid keeps connect() idempotent. * RefreshIfStaleTests — fast path when in sync; refreshes when behind. DB-touching tests use TransactionCleanupMixin + CustomObjectsTestCase + TransactionTestCase consistent with the existing test_polymorphic_fields.py pattern (the dynamic-COT machinery needs committed transactions to create/drop backing tables). Verified via manage.py shell that the module imports cleanly and the pure-Python predicates pass. Runs in CI under testing/configuration.py (via .github/workflows/lint-tests.yaml); on the dev host the debug_toolbar system check blocks all test invocations regardless of target — that's a NetBox dev-checkout config issue, not a test problem. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a Related-Object Tabs section between Installation and Known
Limitations covering:
- The combined "Custom Objects" tab (always-on, no configuration).
- The opt-in per-type tab driven by show_dedicated_tab and its UI/API/CSV/
bulk-edit surfaces.
- Hot-reload across gunicorn workers via the Redis-shared
nbco:tab_registry_version counter and the auto-installed
TabRegistryRefreshMiddleware (operators need not touch MIDDLEWARE in
configuration.py).
- Behavioural notes operators commonly hit:
* hide_if_empty=True hides a dedicated tab until the first instance
references the host model.
* Combined-tab badge count is pre-permission filtering (acknowledged
info leak, tracked for a follow-up PR).
* Slug renames propagate correctly without restart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Typed-tab label on host detail pages now reads the COT's
verbose_name_plural ("AIO Baselines") instead of falling back to
CustomObjectType.display_name's auto-titlecased name ("Aio_baseline")
whenever verbose_name_plural is set.
Observed during the 2026-05-26 all-in-one smoke Run 4: COTs created via
the API with only verbose_name_plural set (no explicit verbose_name)
rendered typed-tab labels using the title-cased internal name. Most COTs
created through the UI populate verbose_name_plural; promoting it to the
preferred label source makes tab text match the user's intent without
requiring them to also set the singular verbose_name.
Fallback to str(custom_object_type) (i.e. display_name) preserves
behaviour when verbose_name_plural is blank.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3234e2a to
f9f67ea
Compare
Two defects surfaced by local code review of the v2 branch:
1. tests/test_related_tabs.py::test_dispatch_uids_idempotent was
comparing dispatch_uid strings against a set of Python int hashes
(`hashes = {hash(u) for u in uids}`), so `entry[0][0] in hashes` was
always False. `before == after == 0` made `assertEqual(before,
after)` pass vacuously regardless of dedup behaviour. It also
iterated only post_save and m2m_changed, missing post_delete (two of
the five dispatch_uids live there).
Fix: compare strings against strings (Django stores dispatch_uid as
`lookup_key[0]` verbatim when provided), iterate post_delete too, and
anchor a non-zero baseline with `assertEqual(before, len(uids))` so a
missing receiver also fails the test.
2. CustomObjectsPluginConfig.ready() called register_tabs() (which
mutates `netbox_custom_objects.urls.urlpatterns` via
`_inject_co_urls`) but never followed up with `clear_url_caches()`.
The registry's own contract (registry.py:303 docstring) requires the
caller to clear URL caches afterwards, and `_do_refresh()` honours
it. The startup path did not — a regression of an earlier CodeRabbit
fix dropped during the v2 rewrite. Any code that resolved URLs
between super().ready() and our register_tabs() (other plugins'
ready(), register_models() side effects, autoreloader cycles) would
leave the resolver cache stale until the next COT mutation kicked
_do_refresh().
Fix: call clear_url_caches() immediately after register_tabs() in
ready(), inside the same try/except.
Verified via manage.py shell on the dev host:
- After two back-to-back connect() calls, the five expected
dispatch_uids are present exactly once across post_save / post_delete
/ m2m_changed.
- clear_url_caches() runs cleanly after a forced URL resolution.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Redis-shared tab-registry counter coordinates ANY multi-process WSGI deployment — gunicorn, uwsgi, multiple containers, or several NetBox hosts sharing one Redis. The docstrings and comments implied it was gunicorn-only. This is a wording-only change; no behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quick thought / question on
|
- combined/tab_partial.html: closing `%}` instead of `}}` in the Field
sort-header link would have raised TemplateSyntaxError on every
combined-tab render with the default columns.
- views/typed.py + views/combined.py: dynamic-model row queries now go
through .restrict(request.user, 'view'), so users with view perms on
the host instance no longer see linked custom-object rows they lack
perms on. Badge counts remain unrestricted by design (no request).
- related_tabs/_bump_remote_version: Redis-down fallback no longer
returns local+1. Advancing local while Redis is down put the worker
ahead of the recovered counter; peers then saw remote <= local and
permanently skipped legitimate refreshes until restart.
- CustomObjectsPluginConfig.ready: after seeding the Redis key, align
_tab_registry_version with the seeded value so the first request on
a fresh worker doesn't trigger a redundant full purge+register.
- registry._purge_injected_urls: anchored the URL-name match on the
underscore separator so a hypothetical future name sharing the
literal prefix (no separator) isn't over-purged.
- views/typed.py: removed dead `len(field_info) >= 5/3` guards on a
fixed 5-tuple contract; replaced with explicit unpacking. The
previous len-check on index 3 was also off-by-one.
- views/typed.py: replaced hard-coded URL name with the canonical
CustomObject._get_viewname('add') so a future rename stays in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_TypedTabView and _TabView extended bare django.views.View, so unauthenticated requests to /<model>/<pk>/custom-objects(-<slug>)/ bypassed LOGIN_REQUIRED and were served rows filtered only by AnonymousUser.restrict(...). Compose ConditionalLoginRequiredMixin the same way ObjectChangeLogView / ObjectJournalView do. Per-COT permission filtering remains via the existing qs.restrict(request.user, 'view'). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@Kani999 My first reaction is that dealing with cache invalidation across multiple workers has been one of the biggest persistent issues in Custom Objects ever since the cache (whose purpose is to prevent infinite recursion during What we've had to do in recent changes (in the 0.5.0 cycle) is introduce caching techniques with versioning/timestamps that force different workers to invalidate their caches. I don't have the details at top of mind, but if you're using Claude to introspect the cache machinery, I'd prompt it with the challenge to use a similar technique to solve this problem — see whether the dynamic model cache can be manipulated when tab display needs to be updated, using the same existing techniques for ensuring cache freshness across workers that are used when COTFs are updated. |
Cross-worker tab-registry hot-reload rode a Redis-shared monotonic counter. Replace it with a (MAX(cache_timestamp), COUNT(*)) snapshot over CustomObjectType — the same cache_timestamp invariant the model cache already uses — so no Redis or separate cache backend is involved. MAX catches create/update; COUNT catches deletion, including the create->delete->create cycle where MAX advances but COUNT returns to its prior value. - middleware compares the DB token each request; signals refresh the local worker on COT save/delete, deferred via transaction.on_commit - bump CustomObjectType.cache_timestamp from an m2m_changed receiver when a polymorphic field's related_object_types changes outside field.save() (shell/scripts/migrations), with a reverse-direction guard. field save/delete already bump it, so signals listen only on CustomObjectType - wire related-tabs signals unconditionally in ready() and seed the local snapshot after initial registration to skip a redundant first-request refresh Update related-tabs tests for the snapshot model; add m2m cache-timestamp coverage (forward + reverse). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse duplicated logic in the tab views onto shared helpers; behaviour is unchanged and covered by the existing related-tabs tests. - _restrict_or_warn(): shared per-row .restrict() fallback with audit logging, used by both the combined and typed tab views - _register_tab_view(): single idempotent tab-registration helper - _build_combined_q(): one OR-fold of per-field reference filters in typed view - remove a dead import and a redundant boolean in the tab templatetag Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both tab views built the same four-way OBJECT/MULTIOBJECT × poly/non-poly reference filter: combined._iter_linked_fields produced filter-kwargs, typed's _build_q_for_field produced a Q. Hoist a single reference_q() into views/_co_common.py as the source of truth and have both consume it; delete typed._build_q_for_field. combined now yields a Q (filter(q)) and SKIPS any field whose Q is empty — an empty Q must never reach filter(), which matches every row of the model (it would leak every custom object of a type onto every host detail page). Add ReferenceQTests locking that empty-Q-means-skip contract and the per-kind filter shapes. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
typed._build_filterset_form was a copy of CustomObjectListView's filter-form builder that mishandled polymorphic fields: get_filterform_field() returns a dict (one sub-field per allowed target type) for poly fields, which the copy stored as a single bogus form field. Replace it with the canonical dynamic_forms.build_filterset_form_class(dynamic_model) — the same builder the list view and the ObjectSelectorView patch use — so the typed-tab filter sidebar expands polymorphic fields like the rest of the plugin. Drops ~22 lines and the now-unused NetBoxModelFilterSetForm / TagFilterField imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
typed._build_typed_table_class was a ~50-line verbatim copy of the table-class construction in views.py's CustomObjectTableMixin.get_table(). Extract it into tables.build_custom_object_table_class(custom_object_type, model) as the single source of truth and call it from both: the mixin (list / detail / bulk views) and the typed related-tab view. field_types is imported lazily inside the builder to avoid a tables<-field_types load cycle. No behaviour change — identical column set, linkified primary text field, and field-specific render_* hooks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@bctiemann — done, and thanks, that was the right instinct. I dropped the Redis counter entirely and rebuilt hot-reload on the |
|
Stepping back, though: even on I think we can delete the entire mechanism by splitting the feature in two: Step 1 — combined "Custom Objects" tab only. One tab per host model; badge/contents/filters all read from the DB per request. No middleware / signals / URL-injection / cache token. Branch: Liveness, by host kind:
Everyday usage (new COTs against existing targets, new objects, edits) is live under every variant. Step 2 — per-COT typed tabs (separate top-level tabs), as a focused follow-up. These give each opted-in COT its own dedicated tab on the host page (native columns, per-field filters, bulk actions). They're the part that genuinely needs runtime URL registration — each typed tab requires its own reversible URL name, which is exactly what drove the hot-reload / URL-injection machinery in this PR. I'd land them on top of the combined tab once it's in, and decide then between liveness options for them specifically. Honestly I lean toward shipping Step 1 (combined tab) first — it's the least noisy, lands the core value, and lets typed tabs come as a focused follow-up rather than carrying the full machinery in one merge. What's your call on the built-in liveness (accept the restart caveat vs. Variant B), and on combined-first vs keeping it all in one PR? |
|
I do like the sound of Step 1 (with the Variant B -- the tradeoff of a "is this referenced?" check (if the plugin is even present?) versus having to worry about the "first time referenced after startup" business seems fine to me). Step 2 may or may not even be necessary or advisable as I understand it -- there can be an arbitrary number of different COTs linking to a core object, which means an arbitrary number of tabs, right? I had thought the shape this was going to take was just a single "Custom Objects" tab that aggregated everything the same way the "Custom Objects linking to this object" card does now. Either way it does seem like a value-add that I'd be happy to split off as a later improvement. |
Summary
Integrates the standalone netbox-custom-objects-tab plugin into core
netbox-custom-objectson top of upstream 0.5.1 (+ post-release fix #524), with full polymorphic Object/Multi-object field support and cross-process hot-reload.Closes #26 · supersedes #434 · re-do of the original PoC (see Update note below).
What's implemented
Combined "Custom Objects" tab — automatically appears on detail pages of any NetBox object referenced by a Custom Object Type Field. Shows all linked custom objects with search, tag/type filters, sortable columns, HTMX-driven pagination, per-user column configuration, and per-row edit/delete actions. Zero configuration — discovery is automatic.
Per-COT typed tabs — opt-in dedicated tabs for specific COTs (
CustomObjectType.show_dedicated_tabBooleanField). Each typed tab provides the COT's native list-view experience: type-specific columns, per-field filters, bulk edit/bulk delete, table configuration, pre-filled "Add" button. Surfaced via the COT edit form, bulk-edit form (tri-stateBulkEditNullBooleanSelect), CSV import, list-view column, detail-template row ({% checkmark %}), and REST API.Polymorphic field support — both single-Object GFK fields and MultiObject through-table fields propagate to tabs correctly across all allowed target types. Cross-contamination prevention via ContentType filtering. Through-table queries use
.distinct()to collapse multi-host links to one row per host. CO-to-CO polymorphic resolves throughapps.get_model()on the dynamic table model.Hot-reload across WSGI worker processes — Redis-shared monotonic counter
nbco:tab_registry_version(seeded on startup so a Redis flush + restart doesn't desync). Signal handlers onCustomObjectType/CustomObjectTypeFieldpost_save+post_deletePLUSm2m_changedonrelated_object_types(the latter catches polymorphic-field target writes that fire afterpost_save). All refreshes are deferred viatransaction.on_commitso rolled-back transactions don't leak Redis bumps.TabRegistryRefreshMiddlewareruns on every request, costs one Redis GET on the sync path, and triggers a registry rebuild +clear_url_caches()when behind. Auto-installed viaPluginConfig.middleware— operators don't have to touch theirMIDDLEWAREsetting.Typed-tab URL injection for host apps.
dcim/urls.py(and friends) capturesget_model_urls('dcim', 'device')once at module load — so newly-registered typed tabs would otherwise be invisible toreverse()until a process restart._inject_host_typed_tab_urls()walks each captured list and replaces its contents in-place after every refresh, so typed-tab URLs are reachable on the next request.ViewTab.visible()defence-in-depth — each typed tab'svisiblecallable re-readsshow_dedicated_tabfrom the DB per render, so even a missed refresh can't leave a stale tab visible.Unit tests (
tests/test_related_tabs.py) — 18 tests across 7 classes covering: registry discovery (poly + non-poly), purge idempotency,ViewTab.visible()gating, signal handlers,refresh_if_stalefast vs. refresh paths,dispatch_uiddeduplication.README documentation of the feature, hot-reload mechanism,
hide_if_emptybehaviour, and known limitations.Verified end-to-end
Tested against a NetBox deployment running this branch:
show_dedicated_tabopt-in surfaces. Form field, bulk-edit (tri-stateBulkEditNullBooleanSelectdropdown — three options: no-change / Yes / No), CSV import, REST API GET + PATCH, list column, detail-page row.show_dedicated_tab=trueand hard-reloading the host page makes the typed tab appear on the next request; PATCHing back tofalsemakes it disappear on the next reload. Combined-tab badge counts also reflect instance create/delete live. Verified across multiple WSGI worker processes.tenancy.tenantas the first reference to that model — causes the combined "Custom Objects" tab to register on tenancy.tenant detail pages on the very next hard reload, without any subsequent COT mutation needed. This exercises them2m_changedsignal handler that catches the API serializer's post-save()M2M write..distinct(). A single Multi-object polymorphic CO linking to a Device, a Site, and a Tenant simultaneously appears as exactly one row per host (not three).ViewTab.visible(). Flippingshow_dedicated_tab=falseand hard-reloading once removes the typed tab on the very first response — the live DB check overrides any process that hasn't yet observed the Redis bump.No NetBox restart was required for any of the user-visible flows after the multi-process hot-reload (signals + middleware + URL injection) was complete.
Known limitations (deferred to follow-up PRs)
viewpermission on a COT sees the inflated count but zero rows once they open the tab. Minor info leak; acknowledged.template_content.pydoes not gate on per-COT view permissions. Pre-existing.?slug=...silently dropped by DRF on the COT-list endpoint because the FilterSet didn't expose it. Standalone fix in Add slug filter to CustomObjectTypeFilterSet #527."<type> None"because post-delete__str__includedself.id=None. Standalone fix in Avoid "<type> None" in CustomObject.__str__ post-delete #528.related_object_typesis immutable after field creation (is_polymorphiclikewise) — upstream validation rejects PATCH with"Cannot change allowed object types after field creation.". This is an upstream constraint, not introduced or addressable in this PR.netbox_custom_objects, separate ticket.Update on this PR (2026-05-26)
The original branch (
feature/related-object-tabs, 36 commits on top of 0.4.10) has been force-updated to a fresh re-implementation on top of 0.5.1 with polymorphic-field support, addressing the rework requested by @bctiemann on 2026-05-06. The previous head is preserved in reflog/refs (d3faf82) but is no longer the active head.The base branch is now
mainrather thanfeature—upstream/featureHEAD is9492e7f(v0.5.0, merge-base) whileupstream/mainis 21 commits ahead at v0.5.1 + the post-release hotfix #524, so the original "targetfeaturefor v0.6.0" advice is obsolete andmainis the right target now.Images
(Carried over from the original PR description.)