Skip to content

Add Add-button on Typed tabs with reverse-reference prefill (closes #9)#10

Merged
Kani999 merged 6 commits into
masterfrom
feature/typed-tab-add-button
May 12, 2026
Merged

Add Add-button on Typed tabs with reverse-reference prefill (closes #9)#10
Kani999 merged 6 commits into
masterfrom
feature/typed-tab-add-button

Conversation

@Kani999
Copy link
Copy Markdown
Collaborator

@Kani999 Kani999 commented Apr 22, 2026

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_add view with the back-reference field pre-populated to the parent object's PK and return_url set 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)

  • Each Typed tab shows a primary blue Add Type button in the bottom toolbar (alongside Bulk Edit / Bulk Delete), at full button height.
  • When a Custom Object Type has multiple back-reference fields to the same parent model (e.g. primary_device and backup_device both → 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.
  • New module-level _build_add_links(slug, pk, field_infos, return_url) helper — pure function, fully unit-tested. Returns [{field_name, label, url}].
  • field_infos tuples 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_tabs sorts each group by field name for deterministic button ordering.
  • Hidden for users without netbox_custom_objects.add_customobject permission (mirrors the perm enforced by customobject_add itself).
  • hide_if_empty=True is unchanged: the tab appears only once the first object is linked (via the native menu); subsequent additions use the new button.
  • Prefill contract: relies on NetBox's ObjectEditView.get() doing initial = normalize_querydict(request.GET). Works uniformly for TYPE_OBJECT (DynamicModelChoiceField) and TYPE_MULTIOBJECT (DynamicModelMultipleChoiceField).

2. b2e5906 — Fix typed-tab URL registration broken by request_started deferral

Pre-existing regression discovered while end-to-end testing the new feature on a real deployment with typed_models configured: typed-tab URL routing was broken since 2.1.0, returning HTTP 404 even though the views were in registry['views'].

Root cause: PR #4 / commit 5bf09c3 deferred typed-tab registration from AppConfig.ready() to a request_started signal handler to silence DB-access startup warnings. But NetBox's get_model_urls() snapshots registry['views'] when each model's urls.py is 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(). The OperationalError/ProgrammingError safety net inside register_typed_tabs still covers the manage.py migrate / fresh-DB case. The DB-access startup warning returns but is informational; broken URL routing was not. Adds a defensive note to CLAUDE.md to prevent re-deferral.

3. 212fbed — Fix typed-tab badge over-counting rows matching multiple fields

Pre-existing bug since 2.0.0; only became visible during multi-FK + M2M testing of the new feature. _count_for_type summed 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_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.

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 Equipment type with 3 Device-pointing fields and 4 rows overlapping on a single device reported badge=9 / table=4 before the fix, badge=4 / table=4 after.

4. b434c9b — Apply PR #10 review fixups

Four small follow-ups from review, all 2.3.0-bound:

  1. Permission check fix — was using get_permission_for_model(dynamic_model, "add"), which yields add_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 base netbox_custom_objects.add_customobject, matching what customobject_add itself enforces server-side.
  2. True Bootstrap split-dropdown — primary action button (uses the first alphabetic field's prefill URL) + separate dropdown-toggle-split chevron that opens the field menu. Adds the required visually-hidden screen-reader label.
  3. Stale version references — replaced lingering 2.3.1 / 2.3.3 references in code comments and test docstrings with 2.3.0 (4 places: views/typed.py, views/__init__.py, tests/test_views_init.py, CLAUDE.md).
  4. add_label fallbackcot.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.

5. fd630d5 — Permission-gate Bulk Edit/Delete on Typed tabs

Surfaced by the Section F smoke test (re-run with the freshly-created non-superusers smoke-yes and smoke-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 had change_customobject / delete_customobject permissions.

Asymmetric with the Add button — b434c9b correctly gated 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 / 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, can_delete), then wraps each bulk-action button individually in the matching {% if %} guard. Per-button (rather than gating the whole toolbar on change AND delete) so a user with only change_customobject 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:

User Add Bulk Edit Bulk Delete Notes
admin (superuser) 1 1 1 No regression.
smoke-yes (only add) 1 0 0 NEW correct behavior — was 1/1/1 before this fix.
smoke-no (only view) 0 0 0 No regression.

Like b434c9b's perm-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. 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 upstream CustomObjectDeleteView ValueError as 2.3.0 Known Issue

The 2.3.0 comprehensive smoke test (Sections A–F all PASS) ALSO surfaced a deterministic ValueError in the upstream netbox_custom_objects plugin (NOT in this plugin) at netbox_custom_objects/views.py:977 inside CustomObjectDeleteView._get_dependent_objects(obj):

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: 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 filter 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. 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 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

Test plan

  • 61/61 unit tests pass; ruff check + ruff format clean.
  • Comprehensive manual smoke test against live deployment on NetBox 4.6.0 + netbox_custom_objects 0.5.0 — full report in a follow-up comment. Highlights:
    • Both combined tab and typed tabs visible on Device detail page.
    • Combined tab: pagination, type filter, tag filter, text search, column sort, per-row Edit/Delete with return_url preservation.
    • Typed-tab badge count = table row count (no over-count from multi-FK overlap — confirms badge fix).
    • Add button at bottom toolbar, full height, alongside Bulk Edit / Bulk Delete.
    • Single-FK type (Maintenance) → single button.
    • Multi-FK type (SmokeTest Equipment, 3 Device-pointing fields) → split-dropdown with one entry per back-reference field in deterministic alphabetical order.
    • FK pre-fill: ?primary_device=4274&return_url=..., field pre-selected as the parent device.
    • M2M pre-fill: ?affected_devices=4274&..., multi-select widget shows device as a chip.
    • Save → returns to the typed-tab URL with new row visible.
    • Filter preservation: applying ?q=... then Add → Cancel returns to the filtered tab; the Add form's return_url carries the filter.
    • Bulk Edit, Bulk Delete, Configure Table modal all work.
    • Direct typed-tab URL /dcim/devices/<pk>/custom-objects-<slug>/ resolves and renders (confirms URL-registration fix).
  • Permission gating completed — re-ran Section F with two non-superuser test accounts (smoke-yes / only add_customobject; smoke-no / only view_customobject). Verified: smoke-yes SEES the Add split-dropdown (proves b434c9b perm-fix); smoke-no does NOT; smoke-no has no per-row Edit/Delete buttons on the combined tab.
  • CO→CO typed tabs (Section E) — re-ran after applying the optional netbox_custom_objects.* config and restarting NetBox. Verified: typed tab appears on Custom Object detail pages with the right badge count; hide_if_empty=True correctly 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.
  • Bulk Edit / Bulk Delete gating — discovered during Section F that these buttons rendered unconditionally; addressed in commit 5 (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.

Kani999 added 3 commits May 12, 2026 11:12
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.
@Kani999 Kani999 force-pushed the feature/typed-tab-add-button branch from b123577 to 212fbed Compare May 12, 2026 09:12
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.
@Kani999
Copy link
Copy Markdown
Collaborator Author

Kani999 commented May 12, 2026

Smoke-test report — comprehensive run on https://krupa.vm.cesnet.cz/

Live deployment: NetBox 4.6.0 + netbox_custom_objects 0.5.0. Two consecutive runs completed all sections (A through F) of the smoke-test plan; one finding (Bulk Edit / Bulk Delete buttons not permission-gated) was discovered and addressed in fd630d5.

Test data provisioned for the runs

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 1 at 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 the title custom field) returns 0 hits even though the row exists. q=SmokeTest Equipment 1 matches 22 rows on the object's string representation. This is the documented behavior of _filter_linked_objects (matches str(obj) + type name + field label, not field values). Workaround for affected types: set primary=True on the title field so str(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 Maintenance button — ✅ (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-yesnetbox_custom_objects.{view,add}_customobject
  • smoke-nonetbox_custom_objects.view_customobject only
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)

  1. Bulk Edit / Bulk Delete unguarded — addressed in fd630d5 (now part of the 2.3.0 release).
  2. B4 search scope — search hits str(obj) only, not field values. Documented behavior; future enhancement candidate, not a 2.3.0 blocker.
  3. 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.* in typed_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.
@Kani999 Kani999 force-pushed the feature/typed-tab-add-button branch from 8c9218a to fd630d5 Compare May 12, 2026 10:26
@Kani999 Kani999 self-assigned this May 12, 2026
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.
@Kani999 Kani999 merged commit 2ba90eb into master May 12, 2026
2 checks passed
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.

[Feature] Add possibility to add additional custom obects from Typed-tab

1 participant