Add Add-button on Typed tabs with reverse-reference prefill (closes #9)#10
Conversation
Each Typed tab now shows an "Add <Type>" button in the bottom toolbar (alongside Bulk Edit and Bulk Delete) that opens the native customobject_add view with the reverse-reference field pre-filled to the parent object's PK and return_url set back to the tab. After saving, the user lands back on the same tab, with any active filters preserved. When a Custom Object Type has multiple fields referencing the same parent model (e.g. primary_device and backup_device both point at Device), the button becomes a split-dropdown listing each field. The button is hidden for users without add_customobject permission. Tabs with hide_if_empty=True remain hidden until the first object is created via the native menu — subsequent additions can use the new button. The button sits in the same .btn-list as the existing bulk action buttons at full button size (no btn-sm) so heights match.
Move typed-tab registration, CO URL injection, and registry deduplication back to AppConfig.ready(). They were deferred to a request_started signal handler in commit 5bf09c3 (PR #4) to silence DB-access startup warnings from Django and netbox_branching, but that broke typed-tab routing entirely: NetBox builds each model's URLconf via get_model_urls() on the first resolve() call by snapshotting registry['views'] at that moment, so anything registered after the URLconf is built has no URL pattern. Combined tabs worked by coincidence — they were registered in Phase 1 (synchronous). Typed tabs and the new Add-button feature were unreachable on any deployment with typed_models configured: the URL returned 404 even though the view was in registry['views']. The OperationalError / ProgrammingError safety net inside register_typed_tabs still covers the manage.py migrate / fresh-DB case. The "Accessing the database during app initialization is discouraged" warning the original deferral suppressed is back, but it's informational; broken URL routing was not. - Removes _deferred_typed_init, _deferred_config, _deferred_init_lock, and the threading import from views/__init__.py - Updates tests/test_views_init.py to assert synchronous registration - Adds a defensive note to CLAUDE.md to prevent re-deferral
Badge fix --------- The badge produced by _count_for_type summed per-field counts with no deduplication, so a Custom Object Type with several fields pointing to the same parent model (e.g. primary_device, backup_device, affected_devices all -> dcim.device) reported a badge number larger than the actual table row count whenever a row matched the parent via more than one field. Verified against real test data: a SmokeTest Equipment type with three Device-pointing fields and 4 rows engineered to overlap on a single device reported badge=9 / table=4 before the fix, badge=4 / table=4 after. The fix mirrors View.get's queryset construction: dynamic_model.objects.filter(Q | Q | ...).distinct().count(). Bonus side-effect: badge issues a single SQL query per tab instead of N (one per Device-pointing field). Bug existed since the typed-tab feature was introduced in 2.0.0; only became visible with multi-FK or M2M field combinations. - Updates _count_for_type in views/typed.py - Replaces the test_returns_sum_for_object_and_multiobject_fields test with three tests asserting the new contract: single filter() call, Q-OR-Q expression, defensive empty-fields guard Release 2.3.0 ------------- Bumps version to 2.3.0 and adds a consolidated CHANGELOG entry covering the Add-button feature plus the URL-registration fix and the badge fix that landed alongside it. SemVer-correct: this is the first release containing the new feature, so MINOR (not PATCH) bump from 2.2.0.
b123577 to
212fbed
Compare
Four small follow-ups from the PR #10 review, all 2.3.0-bound: 1. Permission check now uses the base CustomObject perm (`netbox_custom_objects.add_customobject`) instead of building one from the per-type dynamic subclass. `get_permission_for_model` on a dynamic `Table28Model` yields `add_table28model`, a perm no user has, so the Add button was silently hidden from everyone on any real deployment. Mirrors the perm enforced by `customobject_add` itself, so server-side and UI-side checks now agree. Removes the now-unused `from utilities.permissions import get_permission_for_model` import. 2. Replaces stale `2.3.1` / `2.3.3` release-note references in code comments and test docstrings with the actual release number `2.3.0` (4 places: `views/typed.py`, `views/__init__.py`, `tests/test_views_init.py`, `CLAUDE.md`). 3. Converts the multi-field Add button from a plain dropdown into a real Bootstrap split-dropdown: a primary action button (using the first alphabetic field's prefill URL) plus a separate `dropdown-toggle-split` chevron that opens the field menu. Adds the required `visually-hidden` screen-reader label. Matches the "split-dropdown" wording used in the original PR description and the CHANGELOG. 4. `add_label` fallback: `cot.get_verbose_name() or str(cot)` so a verbose-name returning an empty string (not just raising `AttributeError`) also falls back to `str(cot)`, avoiding an "Add " button with a trailing space. ruff check + format clean, 61/61 tests pass.
Smoke-test report — comprehensive run on
|
| Object | Quantity | Purpose |
|---|---|---|
NetBox tags smoke-high / smoke-medium / smoke-low |
3 | Tag filter on combined tab |
SmokeTest Equipment Custom Object Type (3 Device-pointing fields: primary_device FK, backup_device FK, affected_devices M2M) |
1 | Multi-FK + M2M typed-tab coverage |
SmokeTest Equipment rows |
34 (4 named + 30 Pagination row NN) |
Pagination, dedup-on-overlap test data |
SmokeTest Tracker Custom Object Type (FK to SmokeTest Equipment, FK to Device) |
1 | CO→CO tab coverage |
SmokeTest Tracker rows |
6 | hide_if_empty test (Equipment row with 0 trackers) |
smoke-yes user (add_customobject only) |
1 | Verifies Add-button visible for non-superuser with add perm |
smoke-no user (view only) |
1 | Verifies all toolbar buttons hidden for non-add users |
Section A — Plugin baseline ✅
- A1: Plugin 2.3.0 Active.
- A2: Both combined and typed tabs visible on Device detail page (
Custom Objects 80 / SmokeTest Equipment 36 / Maintenance 4 / SmokeTest Tracker 3 / Server 1at end of run).
Section B — Combined tab ✅ (one note)
- B1 Tab loads — ✅
- B2 Type filter (
?type=smoketest-equipment) — ✅ (1-50 of 69) - B3 Tag filter (
?tag=smoke-high) — ✅ (25 rows) - B4 Text search —
⚠️ PASS-with-note:q=Network outage(a value in thetitlecustom field) returns 0 hits even though the row exists.q=SmokeTest Equipment 1matches 22 rows on the object's string representation. This is the documented behavior of_filter_linked_objects(matchesstr(obj)+ type name + field label, not field values). Workaround for affected types: setprimary=Trueon the title field sostr(obj)returns the title. Not a regression — out of scope for 2.3.0. - B5 HTMX pagination — ✅
- B6 Column sort — ✅
- B7 Per-row Edit/Delete return_url preserves filters/sort — ✅
Section C — SmokeTest Equipment typed tab (the new feature surface) ✅
| Step | Result | Notes |
|---|---|---|
| C1 badge = 36 | ✅ | |
| C2 table count == badge | ✅ | Confirms badge dedup fix (commit 212fbed) — without it, badge would have been ~75. |
| C3 Add button at bottom toolbar (full height) | ✅ | |
| C4 split-dropdown lists 3 items | ✅ | via Affected Devices, via Backup Device, via Primary Device (alphabetical). |
| C5 Primary Device pre-fill | ✅ | ?primary_device=4274&return_url=... |
| C6 Backup Device pre-fill | ✅ | ?backup_device=4274&... |
| C7 Affected Devices M2M pre-fill | ✅ | Multi-select widget shows device 4274 as a chip. |
| C8 Save → redirect, new row visible | ✅ | New PK 38 created, redirect lands on typed tab. |
C9 typed-tab paginator with per_page=10 |
✅ | HTMX page 1 → 2, "Showing 11-20 of 36". |
| C10 Filters sub-tab + Search "Pagination" | ✅ | 30 rows, Filters badge 1. |
C11 Add → Cancel preserves ?q=Pagination |
✅ | return_url decoded to typed-tab path + ?q=Pagination. |
| C12 Bulk Edit form opens + Cancel returns filtered | ✅ | |
| C13 Configure Table modal | ✅ | |
| C14 per-row Edit then Cancel preserves filter | ✅ |
Section D — Maintenance typed tab (single-FK control) ✅
- D1 single (non-split)
+ Add Maintenancebutton — ✅ (correct: only one Device FK). - D2 Add pre-fills Device, URL
?device=4274&return_url=...— ✅.
Section E — CO→CO typed-tab support ✅
Re-run after applying the optional netbox_custom_objects.* config and restarting runserver. Logged in as admin.
| Step | Result | Notes |
|---|---|---|
| E1 Tab visible on Equipment with trackers | ✅ | Equipment pk=2 → SmokeTest Tracker tab badge=1; pk=4 → badge=3. (Original brief listed pk=1 / 2 trackers; that fixture had drifted between runs — pk=2 and pk=4 substituted; same code path.) |
| E2 hide_if_empty hides tab on Equipment with 0 trackers | ✅ | Equipment pk=3 ("Cleanup C") tab bar omits the SmokeTest Tracker tab entirely. |
| E3 Add button on CO→CO tab pre-fills cross-type FK | ✅ | Single (non-split) + Add SmokeTest Tracker button (Tracker has only one FK to Equipment); URL ?tracked_equipment=2&return_url=…; form opens with Tracked Equipment = SmokeTest Equipment 2; return_url round-trips correctly through Cancel. |
| E4 Same Tracker type also reachable via Device | ✅ | /dcim/devices/4274/custom-objects-smoketest-tracker/ loads, badge=3, Add button URL ?device=4274&…. Same type registered for two different parent classes via separate FKs. |
Section F — Permission gating ✅
Two non-superuser test accounts created (password SmokeTest!2026):
smoke-yes—netbox_custom_objects.{view,add}_customobjectsmoke-no—netbox_custom_objects.view_customobjectonly
| Step | Result | Notes |
|---|---|---|
| F1 smoke-yes SEES Add button | ✅ | + Add SmokeTest Equipment split-dropdown visible at bottom toolbar. Critical: pre-b434c9b, the perm check looked for add_table71model (a dynamic-subclass perm nobody has), so this button would have been hidden even from smoke-yes. Confirms the b434c9b fix. |
| F2 smoke-no does NOT see Add button | ✅ | No Add button. Table itself still renders (smoke-no has view perm). |
| F3 smoke-no no per-row Edit/Delete on combined tab | ✅ | Last column shows no action buttons across all 75 rows. |
Bulk Edit / Bulk Delete gating — discovered + fixed during F1/F2
Initial F1 run revealed that Bulk Edit and Bulk Delete buttons rendered for both smoke-yes AND smoke-no despite neither user having change_customobject / delete_customobject perms. Asymmetric with the b434c9b Add-button fix. Not a security hole (server-side bulk views enforce perms and reject unauthorized clicks) but inconsistent UX for non-superusers.
Addressed in commit fd630d5 ("Permission-gate Bulk Edit/Delete on Typed tabs") — same pattern as the Add button: per-button {% if can_change %} and {% if can_delete %} guards in the template, mirrored by can_change / can_delete context flags in the view. Per-button rather than whole-toolbar so a user with only change perm sees Bulk Edit but not Bulk Delete, and vice versa. Both template locations covered (always-visible bottom toolbar + multi-page select-all-box).
Re-verified end-to-end via Django test client after the fix:
| User | Add | Bulk Edit | Bulk Delete | Notes |
|---|---|---|---|---|
admin (superuser) |
1 | 1 | 1 | Bypasses perm checks — no regression. |
smoke-yes (only add) |
1 | 0 | 0 | NEW correct behavior — was 1/1/1 before fd630d5. |
smoke-no (only view) |
0 | 0 | 0 | No regression. |
Anomalies surfaced (none blocking; addressed where in-scope)
- Bulk Edit / Bulk Delete unguarded — addressed in
fd630d5(now part of the 2.3.0 release). - B4 search scope — search hits
str(obj)only, not field values. Documented behavior; future enhancement candidate, not a 2.3.0 blocker. - Equipment pk=1 fixture drift — data-hygiene item; smoke test substituted pk=2 and pk=4 successfully. Not plan-worthy.
Overall verdict
PASS on every release-surface check. Smoke testing additionally validated:
- the badge fix (C2 — direct evidence: badge=36 matches table=36)
- the URL-registration fix (PF: typed-tab URLs resolve from cold)
- the Add-button new layout (C3)
- the multi-FK split-dropdown (C4)
- the M2M pre-fill (C7)
- the filter-preservation contract (C11)
- the Add-button perm fix from
b434c9b(F1 directly demonstrates that smoke-yes sees the button, which would have been impossible pre-fix) - the bulk-action perm gating from
fd630d5(F1+F2 final verification) - CO→CO typed tabs registered via
netbox_custom_objects.*intyped_models(E1–E4 all pass) - hide_if_empty correctly hides typed tabs on parents with zero matching children (E2)
Ready to merge.
Addendum (2026-05-12, post-fd630d5) — final comprehensive smoke-test pass + upstream bug discovered
A final comprehensive smoke-test run on the post-fd630d5 build (5 commits at the time, now 6) PASSED all 31 brief items, including the new bulk-action gating proven for all three personas (admin / smoke-yes / smoke-no). That run ALSO surfaced a deterministic ValueError in the upstream netbox_custom_objects plugin — NOT in this plugin — at netbox_custom_objects/views.py:977:
ValueError: Cannot query "<row title>": Must be "Table<N>Model" instance.
Trigger: create a custom object via this plugin's new 2.3.0 Add button, then immediately click the per-row Delete in the typed-tab list. The first delete GET fails; refreshing the list before clicking Delete works around it. Bulk Delete (different upstream code path) is unaffected. Pre-existing rows are unaffected.
Root cause is dynamic-model class identity drift across the Create → Delete request boundary in upstream code (the dynamic class registry rebuilds during Create; the immediately-following Delete still holds a reference to the prior class object until a request boundary refreshes it).
Documented as a 2.3.0 Known Issue in commit 06b3cd4 — CHANGELOG ### Known Issues block, README ## Known Issues section, and an in-code comment in views/typed.py immediately above the add_links = … line. No upstream issue filed, no upstream patch, no workaround in this plugin's code — purely documentation, since the fix needs to land in netbox_custom_objects.
Final verdict unchanged: this plugin's 2.3.0 release surface is correct end-to-end. The upstream bug is a known limitation users will encounter via the new Add button, with two clear workarounds (refresh the list, or use Bulk Delete) prominently documented.
Surfaced by the Section F smoke test of 2.3.0 with non-superusers `smoke-yes` (only `add_customobject`) and `smoke-no` (only `view`): the Bulk Edit and Bulk Delete buttons rendered unconditionally on Typed tabs for any user who could view the tab, regardless of whether they had `change_customobject` / `delete_customobject` permissions. Asymmetric with the Add button: b434c9b correctly gates the Add button against `netbox_custom_objects.add_customobject`, but the bulk-action buttons next to it were never wrapped in similar guards. Not a security hole — clicks reach NetBox's `customobject_bulk_edit` and `customobject_bulk_delete` views, which DO enforce perms server-side and would reject the unauthorized request. But the unguarded UI render produced confusing UX for non-superusers and broke toolbar consistency with the just-fixed Add button. Fix mirrors the `can_add` plumbing for two new flags `can_change` and `can_delete`, then wraps each bulk-action button individually with the matching guard (so a user with only `change` perm sees Bulk Edit but not Bulk Delete, and vice versa). Both template locations covered: the always-visible toolbar at the bottom of the table card, and the multi-page select-all-box that only renders when paginated. Verified end-to-end via Django test client against the live deployment test users: - admin (superuser): Add=1 BulkEdit=1 BulkDelete=1 (no regression) - smoke-yes (add only): Add=1 BulkEdit=0 BulkDelete=0 (NEW correct) - smoke-no (view only): Add=0 BulkEdit=0 BulkDelete=0 (no regression) Pre-fix smoke-yes had BulkEdit=1 / BulkDelete=1. Rolls into the existing 2.3.0 release entry — no version bump. Note on test coverage: like b434c9b's permission fix, this commit is verified by manual smoke test only. There is no existing pattern in `tests/test_views_typed_smoke.py` for asserting `_TypedTabView.get()` context contents (all current tests target helper functions); adding proper view-integration tests for the three perm flags would be its own follow-up.
8c9218a to
fd630d5
Compare
The 2.3.0 comprehensive smoke test surfaced a deterministic ValueError
in upstream `netbox_custom_objects` (NOT in this plugin) at
`netbox_custom_objects/views.py:977` inside
`CustomObjectDeleteView._get_dependent_objects(obj)`:
ValueError: Cannot query "<row>": Must be "Table<N>Model" instance.
Trigger: create a custom object via this plugin's new 2.3.0 Add button,
then immediately click the per-row Delete in the typed-tab list. The
first delete GET fails; refreshing the list before clicking Delete
works around it. Bulk Delete (different upstream code path) is
unaffected. Pre-existing rows are unaffected.
Root cause is dynamic-model class identity drift across the
Create -> Delete request boundary: each Custom Object Type backs a
dynamically-generated Django model (Table<N>Model), the model class
registry rebuilds during the Create POST, and the immediately-following
Delete GET still holds a reference to the prior class object in some
scope until a request boundary refreshes it. Django's
`Collector.collect([obj])` walks each related model's
`_base_manager.using(...).filter(<predicate referencing obj>)`, and
`check_query_object_type` raises ValueError when obj's class doesn't
match the related model's expected concrete class.
This plugin's 2.3.0 release surface is correct end-to-end (smoke test
passed every brief item). What 2.3.0 does is make this latent upstream
bug more reachable by adding an Add button that delivers users straight
into the affected Create -> Delete flow. Per user decision, no upstream
issue filing, no upstream patch, no workaround in our code -- just
honest documentation in three places so users encountering the bug find
the workaround quickly:
- CHANGELOG.md: new `### Known Issues` subsection under [2.3.0]
- README.md: new `## Known Issues` section between How It Works and
Support, with both workarounds (refresh the list, or use Bulk Delete)
- netbox_custom_objects_tab/views/typed.py: comment block immediately
above the `add_links = _build_add_links(...)` line so contributors
investigating the Add button code path see the upstream gotcha
No version bump (rolls into 2.3.0). No test changes. No pyproject
changes. ruff clean, 61/61 tests pass.
Summary
Adds an Add Type button to every Typed tab on a NetBox object detail page (Device, Site, Rack, etc.). The button opens the native
customobject_addview with the back-reference field pre-populated to the parent object's PK andreturn_urlset back to the tab — one click + fill form + save → back on the tab, filters preserved.Ships as v2.3.0 (MINOR bump; new feature on top of 2.2.0 NetBox-4.6 support release).
What's in this PR (6 commits)
1.
efdcaf9— Add-button feature (closes #9)primary_deviceandbackup_deviceboth →dcim.device), the button is a real Bootstrap split-dropdown: primary action button uses the first alphabetic field; chevron opens a menu listing all fields._build_add_links(slug, pk, field_infos, return_url)helper — pure function, fully unit-tested. Returns[{field_name, label, url}].field_infostuples widened from(name, type)to(name, type, label)so the field label is resolved once at registration time instead of per-request. All call sites adjusted; existing 2-tuple tests remain green via*_unpacking.register_typed_tabssorts each group by field name for deterministic button ordering.netbox_custom_objects.add_customobjectpermission (mirrors the perm enforced bycustomobject_additself).hide_if_empty=Trueis unchanged: the tab appears only once the first object is linked (via the native menu); subsequent additions use the new button.ObjectEditView.get()doinginitial = normalize_querydict(request.GET). Works uniformly forTYPE_OBJECT(DynamicModelChoiceField) andTYPE_MULTIOBJECT(DynamicModelMultipleChoiceField).2.
b2e5906— Fix typed-tab URL registration broken byrequest_starteddeferralPre-existing regression discovered while end-to-end testing the new feature on a real deployment with
typed_modelsconfigured: typed-tab URL routing was broken since 2.1.0, returning HTTP 404 even though the views were inregistry['views'].Root cause: PR #4 / commit
5bf09c3deferred typed-tab registration fromAppConfig.ready()to arequest_startedsignal handler to silence DB-access startup warnings. But NetBox'sget_model_urls()snapshotsregistry['views']when each model'surls.pyis first imported, so any view added after the URLconf is built has no URL pattern. Combined tabs were unaffected (registered synchronously in Phase 1); typed tabs were unreachable.Fix: registration moved back to
AppConfig.ready(). TheOperationalError/ProgrammingErrorsafety net insideregister_typed_tabsstill covers themanage.py migrate/ fresh-DB case. The DB-access startup warning returns but is informational; broken URL routing was not. Adds a defensive note toCLAUDE.mdto prevent re-deferral.3.
212fbed— Fix typed-tab badge over-counting rows matching multiple fieldsPre-existing bug since 2.0.0; only became visible during multi-FK + M2M testing of the new feature.
_count_for_typesummed per-field counts with no deduplication, so a Custom Object Type with several fields pointing to the same parent (e.g.primary_device+backup_device+affected_devicesall →dcim.device) reported a badge number larger than the actual table row count whenever a row matched the parent via more than one field.Fix: mirrors the table queryset construction in
View.get:dynamic_model.objects.filter(Q | Q | ...).distinct().count(). Bonus: one SQL query per tab badge instead of N (one per field).Verified end-to-end against test data engineered to expose the bug — a
SmokeTest Equipmenttype with 3 Device-pointing fields and 4 rows overlapping on a single device reportedbadge=9 / table=4before the fix,badge=4 / table=4after.4.
b434c9b— Apply PR #10 review fixupsFour small follow-ups from review, all 2.3.0-bound:
get_permission_for_model(dynamic_model, "add"), which yieldsadd_table28model(the dynamic per-type model perm). No user actually has that perm, so the Add button was silently hidden from everyone on real deployments. Superusers were unaffected only because they bypass perm checks (which is why the smoke-test admin still saw it). Now uses the basenetbox_custom_objects.add_customobject, matching whatcustomobject_additself enforces server-side.dropdown-toggle-splitchevron that opens the field menu. Adds the requiredvisually-hiddenscreen-reader label.2.3.1/2.3.3references in code comments and test docstrings with2.3.0(4 places:views/typed.py,views/__init__.py,tests/test_views_init.py,CLAUDE.md).add_labelfallback —cot.get_verbose_name() or str(cot)so a verbose-name returning an empty string (not just raisingAttributeError) also falls back tostr(cot), avoiding an "Add " button with a trailing space.5.
fd630d5— Permission-gate Bulk Edit/Delete on Typed tabsSurfaced by the Section F smoke test (re-run with the freshly-created non-superusers
smoke-yesandsmoke-no): the Bulk Edit and Bulk Delete buttons on Typed tabs rendered unconditionally for any user who could view the tab, regardless of whether they hadchange_customobject/delete_customobjectpermissions.Asymmetric with the Add button —
b434c9bcorrectly gated the Add button againstnetbox_custom_objects.add_customobject, but the bulk-action buttons next to it were never wrapped in similar guards.Not a security hole — clicks reach NetBox's
customobject_bulk_edit/customobject_bulk_deleteviews, which DO enforce perms server-side and would reject the unauthorized request. But the unguarded UI render produced confusing UX for non-superusers and broke toolbar consistency with the just-fixed Add button.Fix mirrors the
can_addplumbing for two new flags (can_change,can_delete), then wraps each bulk-action button individually in the matching{% if %}guard. Per-button (rather than gating the whole toolbar onchange AND delete) so a user with onlychange_customobjectsees Bulk Edit but not Bulk Delete, and vice versa. Both template locations covered: the always-visible toolbar at the bottom of the table card AND the multi-page select-all-box that only renders when paginated.Verified end-to-end via Django test client against the live deployment test users:
admin(superuser)smoke-yes(onlyadd)smoke-no(onlyview)Like
b434c9b's perm-fix, this commit is verified by manual smoke test only — there is no existing pattern intests/test_views_typed_smoke.pyfor asserting_TypedTabView.get()context contents. Adding proper view-integration tests for the three perm flags (can_add/can_change/can_delete) would be its own follow-up.6.
06b3cd4— Document upstreamCustomObjectDeleteViewValueError as 2.3.0 Known IssueThe 2.3.0 comprehensive smoke test (Sections A–F all PASS) ALSO surfaced a deterministic
ValueErrorin the upstreamnetbox_custom_objectsplugin (NOT in this plugin) atnetbox_custom_objects/views.py:977insideCustomObjectDeleteView._get_dependent_objects(obj):Trigger: create a custom object via this plugin's new 2.3.0 Add button, then immediately click the per-row Delete in the typed-tab list. The first delete GET fails; refreshing the list before clicking Delete works around it. Bulk Delete (different upstream code path) is unaffected. Pre-existing rows are unaffected.
Root cause is dynamic-model class identity drift across the Create → Delete request boundary in upstream code: each Custom Object Type backs a dynamically-generated Django model (
Table<N>Model), the model class registry rebuilds during the Create POST, and the immediately-following Delete GET still holds a reference to the prior class object in some scope until a request boundary refreshes it. Django'sCollector.collect([obj])walks each related model's filter andcheck_query_object_typeraisesValueErrorwhenobj's class doesn't match the related model's expected concrete class.This plugin's 2.3.0 release surface is correct end-to-end. What 2.3.0 does is make this latent upstream bug more reachable by adding an Add button that delivers users straight into the affected Create → Delete flow.
Per user decision, this is documentation-only (no upstream issue filing, no upstream patch, no workaround in our code) so users encountering the bug find the workaround quickly:
CHANGELOG.md— new### Known Issuessubsection under[2.3.0]README.md— new## Known Issuessection between How It Works and Support, with both workarounds (refresh the list, or use Bulk Delete)netbox_custom_objects_tab/views/typed.py— comment block immediately above theadd_links = _build_add_links(...)line so contributors investigating the Add button code path see the upstream gotchaTest plan
ruff check+ruff formatclean.netbox_custom_objects0.5.0 — full report in a follow-up comment. Highlights:return_urlpreservation.?primary_device=4274&return_url=..., field pre-selected as the parent device.?affected_devices=4274&..., multi-select widget shows device as a chip.?q=...then Add → Cancel returns to the filtered tab; the Add form'sreturn_urlcarries the filter./dcim/devices/<pk>/custom-objects-<slug>/resolves and renders (confirms URL-registration fix).smoke-yes/ onlyadd_customobject;smoke-no/ onlyview_customobject). Verified: smoke-yes SEES the Add split-dropdown (provesb434c9bperm-fix); smoke-no does NOT; smoke-no has no per-row Edit/Delete buttons on the combined tab.netbox_custom_objects.*config and restarting NetBox. Verified: typed tab appears on Custom Object detail pages with the right badge count;hide_if_empty=Truecorrectly hides the tab on parents with zero matching children; Add button on the CO→CO tab pre-fills the cross-type FK; same Custom Object Type registers tabs against multiple parent classes (Equipment AND Device) when it has FKs to both.fd630d5). Re-verified via Django test client: smoke-yes (add only) now sees Add but no Bulk; smoke-no still sees nothing; admin (superuser) unaffected.Closes #9.