Skip to content

Related object tabs#482

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

Related object tabs#482
Kani999 wants to merge 20 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 on 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_tab BooleanField). 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-state BulkEditNullBooleanSelect), 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 through apps.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 on CustomObjectType / CustomObjectTypeField post_save + post_delete PLUS m2m_changed on related_object_types (the latter catches polymorphic-field target writes that fire after post_save). All refreshes are deferred via transaction.on_commit so rolled-back transactions don't leak Redis bumps. TabRegistryRefreshMiddleware runs on every request, costs one Redis GET on the sync path, and triggers a registry rebuild + clear_url_caches() when behind. Auto-installed via PluginConfig.middleware — operators don't have to touch their MIDDLEWARE setting.

  • Typed-tab URL injection for host apps. dcim/urls.py (and friends) captures get_model_urls('dcim', 'device') once at module load — so newly-registered typed tabs would otherwise be invisible to reverse() 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's visible callable re-reads show_dedicated_tab from 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_stale fast vs. refresh paths, dispatch_uid deduplication.

  • README documentation of the feature, hot-reload mechanism, hide_if_empty behaviour, and known limitations.

Verified end-to-end

Tested against a NetBox deployment running this branch:

  • Tab discovery. Combined "Custom Objects" tab appears on detail pages of every NetBox model referenced by a CustomObjectTypeField — covered for non-polymorphic Object/Multi-object FKs and polymorphic GFK + through-table fields, on built-in models (Device, Site, Tenant, Prefix) and on Custom Object Type detail pages (CO-to-CO).
  • show_dedicated_tab opt-in surfaces. Form field, bulk-edit (tri-state BulkEditNullBooleanSelect dropdown — three options: no-change / Yes / No), CSV import, REST API GET + PATCH, list column, detail-page row.
  • Hot-reload without restart. PATCHing show_dedicated_tab=true and hard-reloading the host page makes the typed tab appear on the next request; PATCHing back to false makes it disappear on the next reload. Combined-tab badge counts also reflect instance create/delete live. Verified across multiple WSGI worker processes.
  • Polymorphic field discovery. Creating a polymorphic Object/Multi-object field with allowed target types that no other COT references — e.g., adding tenancy.tenant as 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 the m2m_changed signal handler that catches the API serializer's post-save() M2M write.
  • Cross-contamination prevention. A custom object bound to a Device via a polymorphic field does NOT appear in the combined tab of a Tenant linked by the same field. ContentType filtering in the queryset is honoured.
  • Through-table .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).
  • Defence-in-depth ViewTab.visible(). Flipping show_dedicated_tab=false and 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.
  • COT and field deletion. Removing a COT or a COTField propagates: registry entries purged, captured URL conf lists refreshed, host detail pages no longer show the dropped tabs after the next reload.

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)

  • Combined-tab badge count reflects the total before per-COT view permissions are applied. A user without view permission on a COT sees the inflated count but zero rows once they open the tab. Minor info leak; acknowledged.
  • Base panel ("Custom Objects linking to this object") rendered by template_content.py does 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.
  • CustomObject delete-toast rendered "<type> None" because post-delete __str__ included self.id=None. Standalone fix in Avoid "<type> None" in CustomObject.__str__ post-delete #528.
  • Polymorphic field related_object_types is immutable after field creation (is_polymorphic likewise) — 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.
  • COT DELETE returns HTTP 500 while still removing the row. The trace lands in Django's deletion collector trying to SELECT from a now-dropped polymorphic through table. Long-standing in 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 main rather than featureupstream/feature HEAD is 9492e7f (v0.5.0, merge-base) while upstream/main is 21 commits ahead at v0.5.1 + the post-release hotfix #524, so the original "target feature for v0.6.0" advice is obsolete and main is the right target now.

Images

(Carried over from the original PR description.)

image1 image2 image3 image4 image5

@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

This comment was marked as outdated.

@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 Kani999 force-pushed the feature/related-object-tabs branch from 7efcede to 2ee95e5 Compare May 7, 2026 08:15
Kani999 added a commit to Kani999/netbox-custom-objects that referenced this pull request May 7, 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 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 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!

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>
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from d3faf82 to 9e0de1e Compare May 26, 2026 11:51
@Kani999 Kani999 changed the base branch from feature to main May 26, 2026 11:52
@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 26, 2026

This branch has been completely reworked on top of upstream main (v0.5.1 + #524) with full polymorphic Object/Multi-object field support. The PR description above has been updated to reflect the new implementation — please review against the current description rather than the original one.

Kani999 added a commit to Kani999/netbox-custom-objects that referenced this pull request May 26, 2026
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>
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch 2 times, most recently from 40eb1f7 to 3234e2a Compare May 26, 2026 12:16
Kani999 and others added 10 commits May 26, 2026 14:48
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>
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from 3234e2a to f9f67ea Compare May 26, 2026 12:51
Kani999 and others added 2 commits May 27, 2026 08:01
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>
@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 27, 2026

@bctiemann

Quick thought / question on nbco:tab_registry_version (Redis-based hot-reload)

Before this lands I'd like to get your gut feel on the Redis-counter hot-reload mechanism, because I'm honestly a bit uncomfortable with the complexity it adds and I'd rather hear "yeah, that's fine" or "no, please simplify" from you now than after merge.

Why this mechanism exists at all. Unlike core NetBox tabs, the tabs this PR introduces depend on database state (CustomObjectType rows + show_dedicated_tab), which users can change at runtime. NetBox builds the tab registry and URL conf once in ready(), so without some kind of cross-process signal, a PATCH show_dedicated_tab=true on one worker would not become visible on other workers until a NetBox restart. That's a pretty rough UX for a feature whose whole point is runtime-defined object types.

What I did. A small monotonic counter in Redis (nbco:tab_registry_version) + a thin middleware that GETs it once per request and rebuilds the local registry if it's behind. Cost on the steady-state hot path is ~1 ms (one Redis GET).

What bugs me. It's still a piece of distributed state that has to stay in sync with the DB, every request pays a Redis GET, and the ViewTab.visible() + startup seeding are defence-in-depth specifically because there are more moving parts than I'd like.

What I tried. I built a side branch that strips the Redis machinery and refreshes the registry only in the process that handled the mutation — diff here, single commit 3366144. I deployed it on a server running granian with multiple workers and confirmed the failure mode empirically: after toggling show_dedicated_tab=true, reloading the related detail page shows the tab on some reloads and not on others — the tab is visible only when the request happens to hit the worker that handled the PATCH, and invisible on every other worker. So without the Redis counter + middleware I can't get hot-reload to propagate to all workers; a NetBox restart is the only way to converge them.

What I'd love your input on.

  1. Is there an existing primitive in NetBox/Django land for "tell all workers to invalidate something" that I missed? (I looked but didn't find one.)
  2. If not, are you OK with the Redis-counter approach as it stands, or would you rather accept the restart-required tradeoff and drop the whole mechanism?
  3. Or — third option — is there a different angle entirely (lazy registry rebuild on miss, periodic poll, a TTL on the registry, …) that you'd prefer?

Happy to go any direction. I just don't want to silently merge in a non-trivial coordination primitive without you having had a chance to weigh in.

Kani999 and others added 2 commits May 27, 2026 11:55
- 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>
@bctiemann
Copy link
Copy Markdown
Contributor

@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 get_model invocations of COTs with object/multiobject fields) was introduced. One of the thing we tried hard to avoid having to introduce was a shared cache backend, i.e. in Redis, and thus far we've been successful at that.

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.

@Kani999 Kani999 changed the base branch from main to feature May 29, 2026 06:16
Kani999 and others added 5 commits May 29, 2026 08:51
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>
@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 29, 2026

@bctiemann — done, and thanks, that was the right instinct.

I dropped the Redis counter entirely and rebuilt hot-reload on the cache_timestamp versioning the dynamic-model cache already uses. Each worker keeps a (MAX(cache_timestamp), COUNT(*)) snapshot over CustomObjectType; a thin middleware compares it per request and re-registers tabs only when it drifts. MAX catches creates/updates (every COT + COTField save bumps cache_timestamp, including the m2m_changed path for polymorphic targets); COUNT catches deletes. No shared cache backend — the same DB column that gates the model cache gates the tab registry, so the invariant lives in one place.

@Kani999
Copy link
Copy Markdown
Author

Kani999 commented May 29, 2026

Stepping back, though: even on cache_timestamp, the hot-reload subsystem is heavy — middleware on every request, signal handlers, registry purge/re-register, and the part I trust least, _inject_host_typed_tab_urls() rewriting host apps' frozen urlpatterns in place. It works, but it's a lot of moving parts for a tabs feature, and this PR's size reflects that.

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: feature...feature/related-object-tabs-combined — a clean ~+1.1k-line pure addition, zero model/migration changes. It's purely additive: it does not remove or change the existing "Custom Objects linking to this object" panel (template_content.py) — both surface the same relationships, so if we go this way we should decide whether the tab supersedes that panel or they coexist.

Liveness, by host kind:

  • References between custom object types (CO→CO) are always live — no restart. Custom-object detail pages are rendered by the plugin's own template, so the tab nav-link is computed live from the DB per render, and its URL is a single COT-agnostic route (…/<cot-slug>/<pk>/custom-objects/, slug as a path param) injected once at startup that reverses for any slug — including COTs created later. (Smoke-tested: a brand-new COT referencing another shows the tab immediately, badge updates live.)
  • Built-in NetBox hosts (Device, Site, …): live for everything except the first-ever reference to a NetBox model type nothing referenced at startup — that model's per-model tab URL is frozen into the URLconf at boot, so it needs a restart to appear. (No manage.py command avoids this: a separate process can't mutate live workers' URLconf; only shared polling — what we're deleting — or a restart crosses the process boundary.)
  • Variant B removes even that built-in caveat: register the tab on all models at startup and gate display by the live DB badge → fully live everywhere, still zero machinery, at the cost of a cheap "is this referenced?" check on every detail page.

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?

@bctiemann
Copy link
Copy Markdown
Contributor

bctiemann commented May 29, 2026

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.

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