Skip to content

Add a combined "Custom Objects" related tab#482

Open
Kani999 wants to merge 11 commits into
netboxlabs:featurefrom
Kani999:feature/related-object-tabs
Open

Add a combined "Custom Objects" related tab#482
Kani999 wants to merge 11 commits into
netboxlabs:featurefrom
Kani999:feature/related-object-tabs

Conversation

@Kani999

@Kani999 Kani999 commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

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:

  1. One discoverable surface. Previously a linking relationship surfaced as a static left-column panel with no search, filtering, sorting, or actions. The tab matches the native NetBox child-object list (Device → Interfaces) look and behaviour.
  2. Permission correctness. The old panel rendered linked rows ignoring per-Custom-Object-Type view permissions — a pre-existing info leak. The tab filters by the viewing user's permissions throughout: the linked rows, the Multi-object Value column (polymorphic targets included), and the count badge — and the tab hides entirely when the user may view none of the linked objects.

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.py centralises the four reference shapes in reference_q() (the single source of truth), with an .exists() fast path that keeps the badge cheap on the common detail pages that reference nothing.
  • The badge reads the viewing user from NetBox's current_request and restricts the per-field counts, so it reflects the rows the user can actually open (not a higher, unfiltered total).
  • Styling mirrors ObjectChildrenView: controls row above the table, object-list table with th.orderable headers (?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

  • Native multi-select bulk actions. Rows are heterogeneous Custom Object Type models (and one object can repeat under several linking fields), so NetBox's single-endpoint bulk edit/delete cannot apply. Per-row actions only.
  • Configure-Table omits the saved-table-config "Save" split (that needs a single ObjectType) — matching native, which only shows it when config_params exist.

Tests

netbox_custom_objects/tests/test_related_tabs.py (31 tests): reference_q shapes + empty-Q-means-skip (including the polymorphic multi-object through-table path), per-row and badge permission filtering, _public_host_model_classes inclusion/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.

@CLAassistant

CLAassistant commented Apr 24, 2026

Copy link
Copy Markdown

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

damsitt commented Apr 27, 2026

Copy link
Copy Markdown

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

Kani999 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

@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

Kani999 commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

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

Kani999 commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

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

bctiemann commented May 6, 2026

Copy link
Copy Markdown
Contributor

@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

Kani999 commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

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

Kani999 commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

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!

@Kani999

Kani999 commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

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 from 9e0de1e to 40eb1f7 Compare May 26, 2026 12:10
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from 3eaacbf to 863431b Compare June 4, 2026 09:57
@Kani999

Kani999 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • Dropped the typed-per-COT tabs and the hot-reload/registry-rebuild machinery.
  • Kept + polished the combined "Custom Objects" tab; restyled it to match the native ObjectChildrenView (Device → Interfaces) look.
  • Removed the old "Custom Objects linking to this object" panel (superseded).

The original typed-tabs work is preserved at feature/related-object-tabs-v2 if we want to revisit it as a follow-up.

History (on top of feature):

  1. add combined "Custom Objects" tab
  2. supersede the old linking panel
  3. tests
  4. README
  5. (unrelated) fix literal {toggle_text}/{attrs.title} placeholders in the COT fields actions column

@Kani999 Kani999 changed the title Related object tabs Add a combined "Custom Objects" related tab Jun 4, 2026
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch 2 times, most recently from 0efa86e to f3e758a Compare June 5, 2026 08:10
Kani999 added 6 commits June 5, 2026 11:07
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.
@Kani999 Kani999 force-pushed the feature/related-object-tabs branch from f3e758a to 7c3d9e6 Compare June 5, 2026 10:57
@Kani999

Kani999 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up that removes two sources of redundant queries in the combined tab, both scoped to the tab's own code paths:

  • Multi-object Value column N+1. Non-polymorphic multi-object fields were resolved one row at a time (manager.all() → one through-table query + one target query per row). They're now prefetched once per (model, field) group on the page via the custom M2M manager's get_prefetch_querysets, with permission filtering done in Python (as the polymorphic path already does).
  • Linked-field discovery. The body render and the ViewTab badge both walked _iter_linked_fields, which calls get_model() per linked type — so each page regenerated those models twice. It's now memoized on the request object (with a fresh-build fallback when there's no request context). The cached triples are user-independent, so the per-row .restrict() in each caller still enforces permissions.

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 (CustomObjectTypeMenuItems.__iter__get_model() per type), which remains the dominant per-page query cost. It's best addressed separately — e.g. request-scoped get_model() memoization shared across the menu, tab, and badge.

@Kani999 Kani999 marked this pull request as ready for review June 5, 2026 11:04
@Kani999

Kani999 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

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

@bctiemann

Copy link
Copy Markdown
Contributor

@Kani999 could you resolve the merge conflicts please?

Jan Krupa added 2 commits June 18, 2026 10:10
- 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.
@bctiemann

Copy link
Copy Markdown
Contributor

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. _call_super_ready_once() contract

The comment in ready() explains that super().ready() must run before register_tabs() so the view registry is populated before urls.py freezes it. But _call_super_ready_once() is an internal wrapper whose behaviour isn't visible in this diff. If that wrapper ever skips or defers the call (e.g. for hot-reload idempotence), tabs would be registered before the registry is ready and reverse() calls would fail. Could you add a brief note or docstring confirming that _call_super_ready_once() always delegates to super().ready() synchronously?

2. Silent failure in ready() can mask bugs

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 (checks.Warning) alongside the log so the problem surfaces in manage.py check and doesn't stay invisible in production.

3. _MAX_MULTIOBJECT_DISPLAY = 3 as a plugin config setting

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 PLUGINS_CONFIG setting with 3 as the default, e.g. max_multiobject_display.


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.

@bctiemann bctiemann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above.

@Kani999

Kani999 commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review — addressed all three points. Summary below, screenshots at the end.

1. _call_super_ready_once() contract

Expanded the method's docstring to state explicitly that the first call always delegates to super().ready() synchronously — never deferred, scheduled, or skipped; only repeat calls are no-ops (the duplicate-registration guard). It also notes why the ordering is load-bearing: super().ready() populates registry['views'], which urls.py snapshots when register_tabs() runs, so registering tabs first would break reverse() for CustomObject feature URLs. No behaviour change.

2. Silent failure in ready()

ready() now records the swallowed exception, and a new Django system check (netbox_custom_objects.W002, in checks.py) re-surfaces it in manage.py check alongside the existing logger.exception line — so a failed tab registration is visible rather than invisible. The check stays silent when registration succeeds or is skipped.

Example output when registration fails:

?: (netbox_custom_objects.W002) The combined "Custom Objects" related tab failed to register at startup and will not appear on object detail pages (RuntimeError: ...).
	HINT: Resolve the underlying error (full traceback is in the NetBox log) and restart NetBox.

3. _MAX_MULTIOBJECT_DISPLAY as a config setting

Now a PLUGINS_CONFIG setting — max_multiobject_display (default 3) — read via get_plugin_config at request time, and documented in docs/installation.md.

PLUGINS_CONFIG = {
    'netbox_custom_objects': {
        'max_multiobject_display': 10,  # related objects shown per row before truncating
    },
}

Screenshots

Built-in model detail pagedcim.Site, with the combined Custom Objects tab: count badge, quicksearch, Type filter, sortable columns, and per-row actions, matching the native child-object list styling.

site_detail

Type filter in action — the same tab filtered to a single Custom Object Type.

site_detail_cotype_select

CO→CO detail page — a Custom Object referenced by other Custom Objects, including a polymorphic reference (cotab2_childpolyRef).

co_to_co_display

@bctiemann

Copy link
Copy Markdown
Contributor

@claude /review

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @bctiemann's task in 4m 36s —— View job


Code Review: Add a combined "Custom Objects" related tab

  • Gather PR diff and changed files
  • Review core tab logic (related_tabs/)
  • Review template and template tags
  • Review integration points (__init__.py, checks.py, template_content.py)
  • Review tests
  • Review utilities and other changes

Overview

This 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 CustomObjectLink panel with a proper tab that includes filtering, sorting, HTMX pagination, and — critically — correct per-user permission enforcement. The architecture is clean: no middleware, no Redis, no cross-worker coordination. The badge and tab body are driven entirely by per-request DB reads.

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 / Correctness

1. _batch_multiobject_values reads from Django's private prefetch cache

combined.py:376 accesses obj._prefetched_objects_cache directly after prefetch_related_objects(). This works today because CustomManyToManyDescriptor writes under the same key (field.name), but _prefetched_objects_cache is a Django-internal attribute with no stability guarantee. The right way to read the prefetched result is via the descriptor:

# 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 prefetch_related_objects has already populated the cache, this call is free (no DB hit) and survives Django internals changing. Fix this →

2. _get_field_value — polymorphic MULTIOBJECT falls back to restrict_to_viewable without slicing

combined.py:269-274: for a polymorphic MULTIOBJECT field, when .restrict() raises AttributeError (the polymorphic manager path), the function calls restrict_to_viewable(user, list(qs))[:limit] — but qs at that point is already the full manager result with no size cap. For a field with 1000 linked objects, this fetches all 1000 into memory before slicing. The non-polymorphic branch correctly calls qs[:limit] before materialising. The fix is to cap qs before passing it to restrict_to_viewable:

# 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 limit that are viewable while others before limit are not) but that is the same behaviour the non-polymorphic path has, and is the intended design.

3. _iter_linked_fields fast-path can suppress the tab on stale ObjectType rows

The existence check at combined.py:155-161:

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 is_polymorphic state) without updating the fast path, it could silently suppress the tab. The comment notes this risk, but the predicate and the querysets have no shared abstraction to enforce it. This is a maintainability smell, not a current bug.


Performance / Scalability

4. In-memory pagination with an unbounded result set

_get_linked_custom_objects() fetches all linked objects from the DB into a Python list — across all COTs and all fields — before filtering, sorting, and paginating in memory. For a heavily-referenced model (e.g. a Site used by hundreds of custom objects from multiple COTs), this list could be large. There is no SQL-level LIMIT on the full fetch.

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

_count_linked_custom_objects() issues one COUNT(*) per (field, model, q) triple. Thanks to _linked_fields memoization, this runs once per request per instance. For a model referenced by 15 COT fields, that's 15 COUNT queries on every detail-page render. This is probably fine in practice but is worth noting as the linked-field count grows.


Code Quality

6. _HARDCODED_TAB_NAMES couples template and Python

custom_object_tab_tags.py:11-13:

_HARDCODED_TAB_NAMES = frozenset({'journal', 'changelog', 'contacts', 'custom_objects'})

This must stay in sync with the hardcoded <li> elements in customobject.html. If the template gains or loses a manually-rendered tab (e.g. a future contacts refactor), this set needs a corresponding update. A comment pointing to the template would help future maintainers.

7. tab.html — Configure Table renders even when no config params present

tab.html:64-71 always renders the "Configure Table" button for authenticated users, even though CustomObjectsTabTable uses a fixed column set. The native ObjectChildrenView only shows the button when config_params are non-empty. This is a minor UX inconsistency — the modal will open and work, but it configures something the user can't query differently anyway.

8. Tab ordering in customobject.html ignores weight

customobject.html renders the combined tab hardcoded between the main object tab and plugin_extra_tabs, regardless of COMBINED_WEIGHT = 2000. Other registered tabs rendered by plugin_extra_tabs are weight-sorted, but the combined tab always appears before them. If another plugin registers a lower-weight tab, ordering will be inconsistent. This is cosmetic but could surprise authors of third-party plugins.

9. check_multiobject_display_setting (W003) has no tag

Both new checks in checks.py use @register() with no tag, while the existing check_branching_compatibility also has no tag. All three checks should probably carry a consistent tag (e.g. @register('configuration')) if NetBox's check infrastructure uses tags for filtering. Minor.


Tests

The test suite is solid. A few observations:

  • ReferenceQTests covers all four reference shapes and the two empty-Q guards. Good.
  • CombinedTabQueryTests.test_polymorphic_multiobject_through_is_resolved_and_counted exercises the deepest path (polymorphic MULTIOBJECT through-table subquery) end-to-end. This is the most important regression guard.
  • test_batch_multiobject_values_are_correct_and_dont_scale_per_row locks in the N+1 fix with a CaptureQueriesContext assertion. Excellent.
  • test_rows_and_badge_are_restricted_per_user correctly tests the badge with a current_request context var. Good.
  • No test for the full view render path (search + filter + sort + pagination). Given the unit coverage of helpers, this is acceptable, but a smoke-level client.get(tab_url) test in test_views.py would catch template regressions.

Minor / Nits

  • tab_partial.html:56 — tags column uses {% for t in obj.tags.all %} in the rendered page. Tags are prefetched via prefetch_related('tags') in _get_linked_custom_objects, so this is N+1-free. The prefetch is per-model queryset, which is correct.
  • combined.py:425base_params preserves per_page from the request. But per_page is only included when non-empty. If the user changes per_page in the paginator, it's reflected in sort links. Correct.
  • combined.py:157type_choices filters on [TYPE_OBJECT, TYPE_MULTIOBJECT]. This is the right set, matching what reference_q handles non-emptily.
  • customobjecttype.html literal-placeholder fix ({attrs.title} / {toggle_text}{% trans 'Edit' %}) is a clean unrelated bug fix. Good to include.
  • graphql/types.py — the _filter_viewable refactor to delegate to restrict_to_viewable reduces duplication. Clean.

Summary

The 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.

@bctiemann

Copy link
Copy Markdown
Contributor

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.

@Kani999

Kani999 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@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

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