Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,75 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.3.0] - 2026-05-12

### Added

- **Add button on Typed tabs** ([#9](https://github.com/CESNET/netbox-custom-objects-tab/issues/9)) —
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 → Device), the button
becomes a split-dropdown listing each field. The button is hidden for
users without `add_customobject` permission.

### Fixed

- **Typed-tab URL registration**: typed-tab views are now registered
synchronously inside `AppConfig.ready()` instead of from a `request_started`
signal handler. The earlier deferral (commit `5bf09c3`, PR #4) silenced
some startup warnings but broke typed-tab routing entirely — NetBox's
`get_model_urls()` snapshots `registry['views']` when each model's
`urls.py` is first imported, so any view added afterward has no URL
pattern. Combined tabs were unaffected because they were already
synchronous; typed tabs were unreachable on every deployment with
`typed_models` configured. The `OperationalError`/`ProgrammingError`
safety net inside `register_typed_tabs` still covers the
`manage.py migrate` / fresh-DB case.
- **Typed-tab badge no longer over-counts** rows that match the parent via
multiple fields. `_count_for_type` previously 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. Now uses the same
`Q-OR-Q + .distinct()` pattern as the table queryset, so the badge and
the table always agree. Bonus: one SQL query per tab badge 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.
- **Typed-tab Bulk Edit / Bulk Delete buttons are now permission-gated**
against `netbox_custom_objects.change_customobject` /
`delete_customobject` respectively, matching the gating pattern the
Add button uses. Previously these buttons rendered unconditionally on
Typed tabs; clicks were rejected server-side by NetBox's
`customobject_bulk_*` views but the unguarded UI render was confusing
for non-superusers. Per-button guards (rather than gating the whole
toolbar on `change AND delete`) so a user with only `change` perm
sees Bulk Edit but not Bulk Delete, and vice versa. Surfaced by the
2.3.0 smoke test with non-admin test users.

### Known Issues

- **Upstream `netbox_custom_objects` bug surfaced by the new Add button**:
deleting a custom object **immediately** after creating it via the
2.3.0 Add button (Create → row dropdown → Delete in the typed tab list)
raises `ValueError: Cannot query "X": Must be "Table<N>Model" instance.`
from `CustomObjectDeleteView._get_dependent_objects` (upstream
`netbox_custom_objects/views.py:977`). The error fires only on the
*first* delete GET in that flow; refreshing the list page before
clicking Delete works around it, and Bulk Delete (different code path)
is unaffected. Root cause is dynamic-model class identity drift across
the Create → Delete request boundary in upstream code (the dynamic
model class registry rebuilds during Create, but the immediately-
following Delete request still holds a reference to the previous class
in some scope). Tracked here as a documentation-only release note since
the fix needs to land in `netbox_custom_objects`, not in this plugin.
See README "Known Issues" section for user-facing workarounds.

## [2.2.0] - 2026-05-11

### Changed
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ permissions internally via `get_permission_for_model()`.
- Typed tabs use `custom-objects-{slug}` path prefix — avoids collisions with built-in paths
- Multiple fields of same type → union querysets with `.distinct()`
- Tabs registered at `ready()` — new Custom Object Types need a restart (applies both to typed tabs on native models and to `netbox_custom_objects.*` tabs on Custom Object pages)
- **Do NOT defer typed-tab registration to `request_started` or any post-`ready()` signal.** NetBox's `get_model_urls(app, model)` snapshots `registry['views']` when the model's `urls.py` is first imported (which happens lazily on the first `resolve()` call). Anything added to the registry after that has no URL pattern. Combined tabs work because they're registered in `ready()` synchronously; typed tabs MUST be registered the same way. PR #4 / commit `5bf09c3` deferred typed-tab registration to silence DB-access startup warnings — that change broke typed tabs entirely and was reverted in 2.3.0. The DB-access warning is acceptable; broken URL routing is not.
- `netbox_custom_objects.*` wildcard is special-cased in `_resolve_model_labels()` — dynamic models are discovered via `app_config.get_models()` filtered to `CustomObject` subclasses. **Do NOT call `get_model()` here** — each cache-miss call re-registers journal/changelog tab views, producing duplicate tabs
- `base_template` for CO model instances must be `netbox_custom_objects/customobject.html` — the per-model template (e.g. `netbox_custom_objects/table28model.html`) does not exist
- Tab view `get()` must accept `**kwargs` — CO detail URLs pass `custom_object_type` slug as an extra kwarg alongside `pk`
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,39 @@ The tab displays:
| **Tags** | Colored tag badges assigned to the Custom Object instance; `—` when none |
| *(actions)* | Edit and Delete buttons, each shown only when the user has the corresponding permission |

## Known Issues

### Per-row Delete fails on the first attempt right after Create (upstream bug)

After creating a custom object via the 2.3.0 "Add *Type*" button on a
Typed tab, clicking the per-row **Delete** action in the list **on the
very first attempt** raises a `ValueError` inside upstream
`netbox_custom_objects.CustomObjectDeleteView`:

```
ValueError: Cannot query "<row title>": Must be "Table<N>Model" instance.
```

(at `netbox_custom_objects/views.py:977`, inside
`_get_dependent_objects`). Workarounds:

1. **Refresh the typed-tab list page** between clicking Create and
clicking the per-row Delete. The second `/delete/` GET succeeds.
2. **Use Bulk Delete** instead — it goes through a different upstream
code path and is unaffected.

Pre-existing rows (created in earlier sessions or via the upstream
"Add" menu under Custom Objects → *Type*) are not affected. The bug
originates in dynamic-model class identity drift across the
Create → Delete request boundary in the upstream `netbox_custom_objects`
plugin: 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 (queryset cache,
prefetch, or import-level reference) until a request boundary refreshes
it. Will be tracked and fixed upstream; this plugin's 2.3.0 release
ships with the workaround documented here.

## Support

- Open an issue on [GitHub](https://github.com/CESNET/netbox-custom-objects-tab/issues)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
{% load i18n %}
{% block content %}
{% if table %}
<hr class="mt-0 mb-3">
{# Results / Filters inner tabs #}
<ul class="nav nav-tabs custom-objects-subtabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
Expand Down Expand Up @@ -67,47 +66,89 @@
{% endblocktrans %}
</label>
</div>
<div class="bulk-action-buttons">
{% if can_change or can_delete %}
<div class="bulk-action-buttons">
{% if can_change %}
<button type="submit"
name="_edit"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
{% endif %}
{% if can_delete %}
<button type="submit"
name="_delete"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{{ return_url }}" />
{# Objects table #}
<div class="card">
<div class="htmx-container table-responsive" id="object_list">{% include 'htmx/table.html' %}</div>
</div>
{# Add button + bulk action buttons #}
<div class="btn-list d-print-none">
{% if can_add and add_links %}
{% if add_links|length == 1 %}
<a href="{{ add_links.0.url }}" class="btn btn-primary">
<i class="mdi mdi-plus-thick"></i>
{% blocktrans with label=add_label %}Add {{ label }}{% endblocktrans %}
</a>
{% else %}
{# True Bootstrap split-dropdown: primary button uses the first (alphabetic) field; #}
{# chevron opens a menu listing every reverse-reference field. #}
<div class="btn-group">
<a href="{{ add_links.0.url }}" class="btn btn-primary">
<i class="mdi mdi-plus-thick"></i>
{% blocktrans with label=add_label %}Add {{ label }}{% endblocktrans %}
</a>
<button type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
</button>
<ul class="dropdown-menu">
{% for link in add_links %}
<li>
<a class="dropdown-item" href="{{ link.url }}">{% blocktrans with f=link.label %}via {{ f }}{% endblocktrans %}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endif %}
{% if can_change or can_delete %}
<div class="bulk-action-buttons">
{% if can_change %}
<button type="submit"
name="_edit"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
{% endif %}
{% if can_delete %}
<button type="submit"
name="_delete"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{{ return_url }}" />
{# Objects table #}
<div class="card">
<div class="htmx-container table-responsive" id="object_list">{% include 'htmx/table.html' %}</div>
</div>
{# Bulk action buttons #}
<div class="btn-list d-print-none">
<div class="bulk-action-buttons">
<button type="submit"
name="_edit"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
<button type="submit"
name="_delete"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
</div>
{% endif %}
</div>
</div>
</form>
Expand Down
Loading
Loading