Add a combined "Custom Objects" related tab#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! |
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>
9e0de1e to
40eb1f7
Compare
3234e2a to
f9f67ea
Compare
Quick thought / question on
|
|
@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. |
|
@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. |
3eaacbf to
863431b
Compare
|
Rescoped 🧹 I've force-pushed this branch to a clean combined-only history (5 commits). The earlier exploration carried three things — per-COT typed tabs, the combined tab, and hot-reload machinery — and the combined tab alone delivers the user-facing value without the coordination complexity (no middleware/signals/shared cache; everything is live per-request). What changed vs. the previous push:
The original typed-tabs work is preserved at History (on top of
|
0efa86e to
f3e758a
Compare
Add a single "Custom Objects" tab to the detail page of any NetBox object referenced by a Custom Object Type field. It lists every custom object linking to the object — across all types and Object/Multi-object fields, polymorphic or not — with a count badge, quicksearch, type/tag filters, sortable columns, HTMX pagination, and per-row actions, styled like a native child-object list. The design avoids any coordination machinery (no middleware, signals, or shared cache): the tab view is registered on every public model at startup and gated per request by a live badge, so a newly-referenced model's tab appears on the next request with no restart. Custom-object (CO->CO) hosts are served by one slug-agnostic URL plus a live nav-link, so references between types are live too. Permissions are enforced throughout: linked rows, the multi-object Value column (polymorphic targets included), and the badge are all filtered to what the viewing user may see, and the tab hides when they can view none.
The combined tab renders the same relationships as the old "Custom Objects linking to this object" left-column panel, so the panel is redundant. The tab is strictly better: it enforces per-type view permissions on the rows it lists (the panel ignored them — a pre-existing info leak) and adds search, filters, sorting, and per-row actions. Removes CustomObjectLink/LinkedCustomObject, the now-dead LinkedCustomObjectTable, and the panel-only test; tab coverage lives in tests/test_related_tabs.py.
Cover the surfaces most likely to regress: reference_q's four reference shapes and its empty-Q-means-skip guard (a regression there would leak every custom object onto every host page); per-row and badge permission filtering, including the polymorphic multi-object through path; host-model enumeration and idempotent registration; and the value/search/sort display helpers.
Describe the auto-discovered tab — that it supersedes the old linking panel, enforces per-type view permissions, and is fully live (no restart).
customobjecttype.html copied the field-actions dropdown markup from
CustomObjectTypeFieldTable but left two f-string placeholders unconverted, so
they rendered literally: aria-label="{attrs.title}" and the {toggle_text}
toggle. Replace with {% trans %} tags. Pre-existing (upstream 8b3e74a),
unrelated to the combined tab — kept separate so it can be split out.
Two query-volume fixes for the combined "Custom Objects" tab, both confined to the tab's own code paths. Batch the Value column for non-polymorphic multi-object fields. Resolving each row's targets through manager.all() issued one through-table query plus one target query per row — an N+1 that dominated the tab's query count on pages full of multi-object rows. _batch_multiobject_values now prefetches each (model, field) group on the page in a single pass via the custom M2M manager's get_prefetch_querysets and reads the result from the prefetch cache, filtering targets by view permission in Python (as the polymorphic path already does). Memoize the linked-field discovery per request. The body render and the ViewTab badge both walked _iter_linked_fields, which calls get_model() once per linked type — so every detail page regenerated those models twice. _linked_fields caches the (field, model, q) triples on the request object, collapsing the two passes into one; it falls back to a fresh build when there is no request context. The triples are user-independent, so the per-row .restrict() in each caller still enforces permissions. On a device referenced by ~20 custom object types this cut the tab from 224 to 177 SQL queries and roughly halved SQL time. Adds a regression test asserting multi-object value resolution stays constant in query count as rows grow.
f3e758a to
7c3d9e6
Compare
|
Pushed a follow-up that removes two sources of redundant queries in the combined tab, both scoped to the tab's own code paths:
Added a regression test asserting multi-object value resolution stays constant in query count as rows grow. Out of scope here: the plugin's navigation menu regenerates every dynamic model on each page render ( |
|
@bctiemann I think this is ready for review now. The combined "Custom Objects" tab is feature-complete — count badge, quicksearch, type/tag filters, sortable columns, HTMX pagination, per-row actions, and per-user permission filtering throughout. Let me know if you'd like anything changed. |
What
Adds a single combined Custom Objects tab to every object detail page that is referenced by a Custom Object. The tab lists every Custom Object linking to the object being viewed — across all Custom Object Types and all Object / Multi-object fields (polymorphic and non-polymorphic) — with a count badge, live quicksearch, Type/Tag filters, sortable columns, HTMX pagination, and per-row actions. It works on built-in NetBox models and on custom-object (CO→CO) detail pages alike.
It supersedes the old left-column "Custom Objects linking to this object" panel, which is removed.
Why
Two motivations:
How it works (no coordination machinery)
registry.register_tabs()registers the tab view on every public model (ObjectType.objects.public()— exactly the set a COT Object/Multi-object field can target) at plugin startup, and injects one COT-agnostic URL for custom-object host pages. Display is gated purely by a live per-request, permission-aware badge +hide_if_empty, so a newly-referenced model lights up on the next request with no restart — no middleware, signals, or shared cache.views/combined.pycentralises the four reference shapes inreference_q()(the single source of truth), with an.exists()fast path that keeps the badge cheap on the common detail pages that reference nothing.current_requestand restricts the per-field counts, so it reflects the rows the user can actually open (not a higher, unfiltered total).ObjectChildrenView: controls row above the table,object-listtable withth.orderableheaders (?sort=/-sort), and a compact, keyboard-accessible per-row dropdown (a real<button>toggle — pencil Edit + Changelog + Delete), permission-gated.What is intentionally not here
config_paramsexist.Tests
netbox_custom_objects/tests/test_related_tabs.py(31 tests):reference_qshapes + empty-Q-means-skip (including the polymorphic multi-object through-table path), per-row and badge permission filtering,_public_host_model_classesinclusion/exclusion, idempotent registration, and unit tests for the value / search / sort display helpers.Scope note
This branch is a clean recomposition of the earlier typed + combined + hot-reload exploration into the combined tab only. The typed-per-COT tabs and the hot-reload machinery are deliberately dropped as out of scope for this PR; that exploration is preserved on
feature/related-object-tabs-v2.