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
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. |
|
@Kani999 could you resolve the merge conflicts please? |
- test_api.py: import create_token from .base (moved upstream); drop the now-unused RequestFactory/Token and legacy-panel imports. - tab tags: add 'contacts' to _HARDCODED_TAB_NAMES so upstream's hardcoded Contacts <li> isn't duplicated by plugin_extra_tabs.
The combined related tab filtered multi-object target visibility one object at a time (has_perm per object), a permission-check N+1 left over after the data fetch was already batched. The upstream merge added graphql/types.py:_filter_viewable, which batches the check to one restrict(user, 'view').filter(pk__in=...) per model. Extract that logic into a shared utilities.restrict_to_viewable() and use it in both places: _filter_viewable now delegates to it, and combined.py's polymorphic path and non-polymorphic group resolver call it instead of the per-object check. This also unifies the tab's permission filtering on .restrict(user, 'view'), already used for its other querysets, and drops the now-unused _user_can_view helper.
|
Nice PR — the architecture is clean, the permission fix is important, and the test coverage is thorough. A few things worth addressing before merge: 1. The comment in 2. Silent failure in try:
register_tabs()
except Exception:
logger.exception("related_tabs.register_tabs() failed; continuing without tabs")Swallowing a registration failure means users see no tabs and no indication why — a broken import or a logic error at startup gets logged and silently discarded. Consider raising a Django system check warning ( 3. The truncation limit for multi-value display is currently a hardcoded constant. Users with denser relationships (10+ related objects per row) will always see a trailing "…" with no way to control it. A low-effort improvement would be to expose this as a Also — could you add a screenshot or two of the tab in action (either in the PR description or as a follow-up comment)? It would be helpful to see what the tab looks like on a built-in model's detail page and on a CO→CO page before merging. |
|
@claude /review |
|
Claude finished @bctiemann's task in 4m 36s —— View job Code Review: Add a combined "Custom Objects" related tab
OverviewThis PR adds a combined "Custom Objects" tab to every NetBox object detail page that is referenced by a Custom Object field. It replaces the old left-column Overall this is high-quality work. The permission fix alone justifies the PR. The concerns below are mostly architectural trade-offs to be aware of, with a few concrete bugs. Bugs / Correctness1.
# Instead of:
list(getattr(obj, '_prefetched_objects_cache', {}).get(field.name, []))
# Use the descriptor, which reads from the cache via its __get__:
list(getattr(obj, field.name).all())Since 2.
# cap before calling restrict_to_viewable
return restrict_to_viewable(user, list(qs[:limit]))Note that this changes the semantics very slightly (you'd lose objects past 3. The existence check at if not CustomObjectTypeField.objects.filter(
Q(related_object_type=content_type, is_polymorphic=False)
| Q(related_object_types=content_type, is_polymorphic=True),
type__in=type_choices,
).exists():
return…must mirror the two subsequent querysets exactly. If the two querysets are ever changed (e.g. to add a third Performance / Scalability4. In-memory pagination with an unbounded result set
This is an inherent consequence of the heterogeneous-row design (objects from different models cannot be sorted/filtered in a single SQL query). It is acceptable for typical deployments, but worth documenting as a known limitation (similar to how the PR already notes "no bulk actions for the same reason"). A pragmatic guard — e.g. a cap on the total number of rows shown, with a "showing the first N" notice — would mitigate the worst case without redesigning the architecture. 5. Badge issues O(linked-field) COUNT queries per render
Code Quality6.
_HARDCODED_TAB_NAMES = frozenset({'journal', 'changelog', 'contacts', 'custom_objects'})This must stay in sync with the hardcoded 7.
8. Tab ordering in
9. Both new checks in TestsThe test suite is solid. A few observations:
Minor / Nits
SummaryThe PR is in good shape. Items 1 and 2 are concrete bugs worth fixing before merge. Item 4 (in-memory pagination) is the most significant architectural trade-off and at minimum warrants a sentence in the docs/known-limitations. Items 3, 5–9 are lower priority. The permission enforcement and the removal of the old panel are both clear wins. |
|
Thanks @Kani999 -- I think we're very close now. Please look over the Claude-generated review above; it looks to me as though items 1 and 2 are worth fixing, but the remaining ones are documentation/comments or optional optimizations. It's up to you whether to address any of those, but with 1 and 2 done I'm OK with merging this. |
|
@bctiemann Thanks! I didn't have time to look at this yet, but I hope to get to it during next week. I'll address items 1 and 2 and update the PR |



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.