Skip to content

Related object tabs#482

Draft
Kani999 wants to merge 36 commits into
netboxlabs:featurefrom
Kani999:feature/related-object-tabs
Draft

Related object tabs#482
Kani999 wants to merge 36 commits into
netboxlabs:featurefrom
Kani999:feature/related-object-tabs

Conversation

@Kani999
Copy link
Copy Markdown

@Kani999 Kani999 commented Apr 24, 2026

Summary

Integrates the standalone netbox-custom-objects-tab plugin into core netbox-custom-objects (ref: CESNET/netbox-custom-objects-tab#8).

Closes #26

What's implemented

  • Combined "Custom Objects" tab — automatically appears on detail pages of any NetBox object referenced by a Custom Object Type. Shows all linked custom objects with actions, tags, column config, and quick search. Always active, no configuration needed.
  • Per-COT typed tabs — opt-in dedicated tabs for specific Custom Object Types, with type-specific columns, per-field filters, and bulk actions. Enabled per Custom Object Type via the Show as dedicated tab option on each COT's edit page (admins flip it in the UI; bulk-edit, CSV import, and the REST API also expose it). When unchecked, the COT's objects appear under the consolidated tab. Changes take effect on the next page load without a service restart.
  • CO-to-CO support — custom objects referencing other custom objects also get tabs.
  • Runtime tab registry refresh — a Redis version counter (co:tab_registry_version) is bumped by post_save/post_delete signals on CustomObjectType and CustomObjectTypeField. A thin middleware (TabRegistryRefreshMiddleware) checks the counter on every request and re-runs idempotent tab registration + URL injection on mismatch. Cost: one Redis GET per request; re-registration runs only on version change. Works correctly across multiple Gunicorn/Granian workers without restart.
  • Plugin wiring (__init__.py), templates, and README documentation.

Resolved during review

  • Configuration approach: replaced the static typed_tab_slugs list with a per-COT show_dedicated_tab BooleanField (commits 977feeefd4155b), per @damsitt's suggestion. Surfaces the toggle on the edit form, bulk-edit, CSV import, list table, API serializer, and detail page.
  • Live re-registration: toggling show_dedicated_tab or creating a new COT now takes effect on the next page load across all workers, with no restart required. Implemented via:
    • ViewTab.visible() predicate — reads show_dedicated_tab live from the DB per request (handles toggle on existing COTs)
    • signals.pypost_save/post_delete on CustomObjectType + CustomObjectTypeField bump co:tab_registry_version in Redis via transaction.on_commit
    • middleware.py (TabRegistryRefreshMiddleware) — calls refresh_tab_registry_if_stale() on every request; re-runs register_all_tabs() + URL conf mutation + clear_url_caches() on version mismatch (handles new-COT creation)
    • _inject_typed_tab_urls_into_app_urlconfs() — mutates the live Python list that each app's include() wraps, so Django's rebuilt resolver sees new typed-tab URL patterns after clear_url_caches()
  • Template/styling: tab templates align with NetBox core conventions.

Known limitations

  • Slug rename: renaming a COT's slug leaves the old tab entry in the registry for the lifetime of the process. Both the old and new tab appear simultaneously on related object detail pages (they render the same data — the view keys on cot_pk, not the slug). A process restart clears stale entries. Proper fix would track the old slug via pre_save and remove it from the registry; left as a future improvement.
  • Redis flush/restart: after a Redis flush the version key is absent. Workers treat this as "up to date" and skip re-registration — intentional, since tabs registered at startup remain valid. Tabs for COTs created after the last Redis flush would be missing until any COT save re-seeds the key and triggers a full re-registration on all workers.
  • Combined tab badge count: the badge on the "Custom Objects" combined tab reflects the total count before permission filtering. A user who can't view a COT will see a non-zero badge but 0 rows in the tab body (minor info leak).
  • Base panel permissions: the "Custom Objects" side panel rendered by the base template extension lists all linked objects without gating on per-COT view permissions. This is pre-existing behaviour, not introduced by this PR.

Images

obrazek obrazek obrazek obrazek obrazek

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 24, 2026

CLA assistant check
All committers have signed the CLA.

@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from fda8748 to 9d84499 Compare April 27, 2026 06:34
@damsitt
Copy link
Copy Markdown

damsitt commented Apr 27, 2026

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.

@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 4, 2026

@damsitt thanks for the suggestion — done. Replaced typed_tab_slugs with a per-COT Show as dedicated tab BooleanField on CustomObjectType. The toggle is reachable from the COT edit form, bulk-edit, CSV import, the REST API, and shows up as a column on the list view. When ticked, the COT renders its own typed tab on related-object detail pages; when unticked, its objects fall back into the consolidated "Custom Objects" tab. Restart-on-change for now (registration runs once in ready()), with a TODO in tab_views.py for live re-registration as a follow-up.

12 commits, 977feeefd4155b. The typed_tab_slugs plugin-config setting is gone (no deprecation since this PR isn't merged yet — clean break).

@bctiemann
Copy link
Copy Markdown
Contributor

@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?

Kani999 added a commit to Kani999/netbox-custom-objects that referenced this pull request May 6, 2026
- __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)")
@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 6, 2026

Smoke Test Results

# Scenario Where to look Expected ✅/❌ Notes
1 Dedicated tab – Device (smoke-dedicated-device, dedicated=on) Any of the first 3 devices Own "Smoke Dedicated Device" tab visible
2 Combined tab – Device (smoke-combined-device, dedicated=off) Same devices as above Object appears only in "Custom Objects" combined tab, no dedicated tab
3 Dedicated tab – Site (smoke-dedicated-site, dedicated=on) Any of the first 3 sites Own "Smoke Dedicated Site" tab visible
4 Combined tab – Site (smoke-combined-site, dedicated=off) Same sites as above Object appears only in "Custom Objects" combined tab, no dedicated tab
5 Dual-model COT (smoke-dual-model, dedicated=on, references Device + Site) Any linked device AND any linked site Dedicated tab appears on both the Device and the Site detail pages
6 All field types (smoke-all-fields, dedicated=on) Any of the first 3 devices Tab shows all 13 field types rendered correctly — no stray em-dashes where values exist
7 Empty dedicated tab (smoke-empty-dedicated, dedicated=on, 0 objects) Any device detail page Tab is not visible
8 Live toggle ON (smoke-toggle, start dedicated=off) Edit the COT → toggle "Show as dedicated tab" ON → open a linked device Dedicated tab appears without restarting
9 Live toggle OFF (continue from #8) Toggle back OFF → reload the device Dedicated tab disappears without restart
10 Slug rename duplicate (rename any dedicated COT's slug) Device/Site detail page after save ⚠️ Both old and new tabs appear simultaneously (known limitation — clears on restart)
11 Permission gating Log in as user without view permission on a smoke COT Tab is not visible for that COT comment
12 Create new COT at runtime Create a brand-new COT with dedicated=on via UI Tab appears on referenced object pages without restarting

Import Test Data

smoke_data.json
import_smoke_data.py

@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 6, 2026

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 Known Limitations comment in tab_views.py. It's an edge case — renaming slugs isn't a common operation — but I wanted to flag it explicitly.

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.

@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 6, 2026

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):
Still visible and shows all 5 linked objects. This panel is rendered by the base plugin template extension and doesn't gate on per-COT permissions — it lists everything. That's probably worth a follow-up: ideally it would hide rows the user can't access, but it's existing behaviour, not a regression from this PR.

"Custom Objects" combined tab (the tab injected at the top of the device detail page):
The tab badge shows 1 (correct — there is 1 non-dedicated object linked to this device), but after clicking, 0 rows are rendered. The tab body does respect permissions correctly; it's only the badge count that leaks the existence of an object the user can't see.

Dedicated tabs:
Not visible at all for this user — correct.

Summary:

  • Badge count on the combined tab is a minor info leak (count visible, data not)
  • Base panel ignores per-COT permissions entirely (pre-existing)
  • Dedicated tabs are correctly hidden
  • Tab body correctly renders 0 rows when user lacks permission

Neither issue is introduced by this PR, but worth noting before v0.5.0 ships.

@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from 4dadc29 to 7efcede Compare May 6, 2026 08:16
@Kani999 Kani999 changed the title [WIP] Related object tabs — PoC for discussion Related object tabs May 6, 2026
@bctiemann
Copy link
Copy Markdown
Contributor

bctiemann commented May 6, 2026

@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 feature branch. Which, note, is what this PR will need to target, not main.)

The main thing I'm worried about is polymorphic object/multiobject fields, which are just about to land in feature and go out with v0.5.0. There is a lot of movement in the code there that will very likely impact this PR/feature, and I'd like polymorphism to settle first to give us a chance to ensure related object tabs are baked well and support polymorphism properly. I don't want to rush this.

@bctiemann bctiemann modified the milestone: Future Minor Release May 6, 2026
@Kani999 Kani999 changed the base branch from main to feature May 7, 2026 06:23
Kani999 added 14 commits May 7, 2026 08:26
- Add tab_views.py with combined and typed tab view factories
- Combined tab shows all linked custom objects with actions, tags, column config, quick search
- Typed tabs show per-COT filtered view with bulk actions and per-field filters
- Auto-discover referenced models from app registry (never call get_model during registration)
- Support CO-to-CO tabs (custom objects referencing other custom objects)
- Badge callables use OR + distinct to avoid double-counting
- Add combined_tab.html and typed_tab.html templates
- Add typed_tab_slugs to default_settings in PluginConfig
- Call register_all_tabs() in ready() before super().ready()
- Call inject_co_urls() and deduplicate_registry() after super().ready()
- Add model_view_tabs to customobject.html for CO-to-CO tab support
- Fix JournalEntryTable and ObjectChangeTable: remove unsupported user kwarg
- Fix journal/changelog views to pass self.tab instead of string literals
- Add Related Object Tabs section explaining combined and typed tabs
- Document typed_tab_slugs PLUGINS_CONFIG setting with example
- Note that restart is required after config changes
- Replace _build_filterset_form with shared dynamic_forms.build_filterset_form_class
  (per prior PR netboxlabs#445 feedback / commit 26a39a5 invariant)
- Extract _build_link_q helper; removes copy-pasted Q loop from
  _count_for_type._badge and TypedTabView.get
- Drop CustomObjectType.objects.get(pk=cot_pk) refetch from typed-tab badge;
  use captured cot directly
- Narrow bare except Exception to (OperationalError, ProgrammingError) in
  _count_linked and _get_linked_objects
- prefetch_related('tags') in _get_linked_objects — kills per-row tag N+1
- Switch .restrict() try/except AttributeError to hasattr() guard; matches
  NetBox core pattern in netbox/views/generic/feature_views.py
- Fix stale module docstring: typed-tab opt-in is typed_tab_slugs in
  PLUGINS_CONFIG, not show_tab=True
Apply .restrict(user, 'view') to linked-CO querysets so users without
view permission on a referenced Custom Object model don't see its rows
rendered in the related-object tab bodies.

- _get_linked_objects now takes a user and restricts each per-model qs
  before filtering; the combined-tab view passes request.user.
- TypedTabView.get restricts dynamic_model.objects before the link-Q
  filter so the typed-tab body is also gated.

The combined-tab badge count can still include rows the user can't see;
the body render itself is now restricted.
Pass a permission string into ViewTab so NetBox's core tab template tag
skips the typed tab entirely for users without <app>.view_<model>
permission on the underlying Custom Object model. Previously the badge
count could include restricted rows while the body correctly hid them,
producing a badge-vs-empty-body mismatch.

The permission string is derived from the CO model resolved via
model_ct_map at registration time (cot.object_type_id -> model), so no
cot.get_model() call is introduced during tab registration.

The combined tab is left unguarded intentionally — it aggregates across
all CO types and is filtered per-row by the restrict() fix already in
place.
Kani999 added 20 commits May 7, 2026 08:36
ViewTab.visible now reads show_dedicated_tab per request from the DB,
so toggling the flag takes effect on the next request without
re-registration. Tolerates missing COTs and DB errors by hiding the tab.
_register_typed_tabs now registers a typed-tab view for every COT with
an OBJECT/MULTIOBJECT field, leaving display gating to the ViewTab.visible
predicate. This makes "show_dedicated_tab False at startup, toggled True
later" work without restart, since the view is already registered.

Removes the approach-c-backlog TODO (resolved by this change plus the
upcoming refresh / signal mechanism).
Defends against bookmarked URLs hit after a flag flip. Visibility
predicate hides the tab from the strip; this 404s the URL itself.

Tests use RequestFactory + direct view invocation rather than the
test Client because dynamically-registered NetBox typed-tab URLs
aren't wired into the test environment's URL conf (URL conf is
frozen at app-startup).
Workers keep a local version counter; on each request, middleware (next
commit) compares against the Redis key co:tab_registry_version and
re-runs idempotent register_all_tabs + inject_co_urls + clear_url_caches
on mismatch. Lock-protected with double-checked version read for
threaded worker setups. Cache failures are swallowed.
Calls refresh_tab_registry_if_stale on every request so workers
self-heal their typed-tab registry when the Redis version key has
been bumped by another worker's COT save/delete.
post_save and post_delete handlers, wrapped in transaction.on_commit,
increment co:tab_registry_version. The middleware in each worker
detects the bump and re-runs the (idempotent) registration logic.
Skipped during migrations; cache failures are swallowed.
Validates the full chain: create new COT, signal bumps version,
refresh registers the typed-tab view, related-model detail page
renders the new tab — all without restart.

Test calls refresh_tab_registry_if_stale() explicitly because plugin
middleware activation in the test client is flaky for the
post-startup-registered URL/registry path.
When a brand-new COT was created with show_dedicated_tab=True and
then given an OBJECT/MULTIOBJECT field, the dedicated tab did not
appear until a service restart. Two separate root causes:

1. The post_save signal only watched CustomObjectType. Workers that
   refreshed after the COT was created (before any field existed)
   found no referenced model and advanced their local version to
   match Redis. When the field was later added, no second signal
   fired, so those workers never re-ran register_all_tabs().
   Fix: also connect bump_tab_registry_version to CustomObjectTypeField
   post_save/post_delete.

2. Even after register_all_tabs() ran at runtime, the new tab's URL
   was absent from Django's compiled URL conf. get_model_urls() is
   called once at app URL conf import time and the resulting list is
   static; register_model_view() at runtime adds to registry['views']
   but never causes get_model_urls() to re-run.
   Fix: _inject_typed_tab_urls_into_app_urlconfs() finds the inner
   Python list that include() wraps for each model's <pk>/ resolver
   and appends the new URL pattern directly, so clear_url_caches()
   causes the rebuilt resolver to find it.
The third sentence warned that a service restart was required for
changes to take effect. That limitation no longer exists.
- signals.py: pass timeout=None to cache.add() so the
  co:tab_registry_version key never expires. Without this Django's
  default 300s TTL resets the counter, causing workers whose local
  version is above the reset value to skip re-registration.
- tab_views.py: reword the ordering comment in
  refresh_tab_registry_if_stale to accurately describe why
  _local_tab_registry_version is advanced last.
- test_tab_views.py: add URL-injection assertion to
  test_refresh_registers_newly_created_cot — verifies that
  _inject_typed_tab_urls_into_app_urlconfs() mutated dcim.urls so
  Django's URL resolver finds the new typed tab after clear_url_caches.
- Move Http404 to module-level imports (was a local import inside TypedTabView.get)
- Import CustomObjectType alongside CustomObjectTypeField at the top
- Replace `CustomObjectType as COTModel` local alias with the already-imported name
- Remove four WHAT-comments that duplicated what the code already said
typed_tab: add custom-objects-subtabs/mb-3 to sub-tab nav, drop extra mt-3
from results pane, add select-all box for multi-page bulk ops, wrap bulk
buttons in btn-list/bulk-action-buttons, rename labels to Bulk Edit/Delete
with trans, add block.super to modals block.

combined_tab: replace inline style with ps-1, distinguish empty-state
message when search is active.
… field

Without this widget, the "No change / Yes / No" three-state semantics
required by NetBox's bulk-edit machinery were broken — Django's default
NullBooleanSelect renders "Unknown / Yes / No" which the bulk-edit view
does not interpret correctly.
- Expand signals.py docstring: document Redis-flush/restart recovery path
- tab_views.py module docstring: add Known Limitations section describing
  slug-rename duplicate-tab behaviour (both tabs appear until restart)
- Move Http404 import to the django.* group (ruff I-001)
- Fix __module__ string in _build_typed_table_class: 'database.tables'
  was a stale leftover from the original tab plugin; corrected to
  'netbox_custom_objects.tab_views' for accurate tracebacks
- Replace model_name.startswith('table') in inject_co_urls with a
  set-membership check against actual registered CO models, removing
  implicit coupling to the dynamic model naming scheme
- Strengthen test_edit_form_persists_show_dedicated_tab: assert 302
  redirect instead of accepting 200, so form validation failures are
  caught rather than silently passed
- __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)")
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from 7efcede to 2ee95e5 Compare May 7, 2026 08:15
@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 7, 2026

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 main to feature and rebased onto the current feature head, so it's MERGEABLE now and the diff reflects only the tab work. I'll keep an eye on #442 and rebase again once polymorphism lands, so this is ready to revisit whenever v0.6.0 is being cut.

Kani999 added 2 commits May 7, 2026 10:40
When a source COT has object fields pointing to two or more different CO
target types, inject_co_urls() deduplicates by tab name and only keeps
one URL pattern (wildcard <str:custom_object_type>). The closure-captured
model_class then routes all targets through the first-registered target's
queryset, returning wrong data or a spurious 404 on the second target.

Fix: re-resolve the target model from the URL slug when the captured
model_class belongs to the CO app — mirroring the identical pattern
already used in CombinedTabView.get().
The per-row actions cell had an unclosed <a> tag and a duplicated
dropdown-toggle anchor, leaving users without an edit link and
rendering two carets. Replace with a proper edit anchor pointing at
customobject_edit and a single dropdown toggle, matching the pattern
used in customobjecttype.html.
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from b0f88da to d3faf82 Compare May 7, 2026 08:40
@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 12, 2026

Heads up — this is not ready to merge yet.

I need to:

  • Rework the code to align with the polymorphic feature
  • Re-run the test suite against the updated implementation
  • Add/adjust tests as needed

I'll push the revised commits and updated test results once that's done.

@jeremystretch jeremystretch marked this pull request as draft May 12, 2026 12:21
@jeremystretch
Copy link
Copy Markdown
Contributor

@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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto generation of tabs in other objects that lists related Custom Objects

5 participants