Skip to content

Commit 4edd86f

Browse files
authored
Merge pull request #13 from CESNET/12-feature-support-polymorphic-object-multiobject-fields-from-netbox-custom-objects-v050
Add polymorphic Object/MultiObject field support + release 2.4.0
2 parents 2ba90eb + 86dec09 commit 4edd86f

8 files changed

Lines changed: 502 additions & 97 deletions

File tree

CHANGELOG.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,80 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.4.0] - 2026-05-12
9+
10+
### Added
11+
12+
- **Polymorphic Object / MultiObject field support**
13+
([#12](https://github.com/CESNET/netbox-custom-objects-tab/issues/12)) —
14+
`netbox-custom-objects` v0.5.0 introduced `is_polymorphic=True` fields
15+
whose targets live in the `related_object_types` M2M (and, for MultiObject,
16+
in a per-field through table keyed by `(source_id, content_type_id, object_id)`).
17+
The plugin's discovery logic previously filtered only on the single-FK
18+
`related_object_type`, so polymorphic links were invisible: tabs disappeared
19+
from referenced hosts (Device, Interface, Site, other CO Types) until the
20+
field was switched back to non-polymorphic. Both tab styles now mirror
21+
upstream's `CustomObjectLink.left_page` query shape — a non-polymorphic
22+
queryset plus a polymorphic queryset, with per-field branching into four
23+
reverse lookups (FK column, GFK `(content_type_id, object_id)` pair, M2M,
24+
polymorphic through table).
25+
- **Polymorphic-field "Add *Type*" toolbar on typed tabs** — the toolbar
26+
shortcut now works for polymorphic Object and MultiObject fields using
27+
upstream's add-view sub-field prefill format: `?<name>__ct=<host_ct>&<name>__obj=<host_pk>`
28+
for polymorphic Object, and `?<name>__<host_app>__<host_model>=<host_pk>`
29+
for polymorphic MultiObject (the upstream form synthesizes one
30+
`DynamicModelMultipleChoiceField` per allowed target type; we fill only
31+
the one matching the host). The previous behaviour silently skipped the
32+
Add link for polymorphic fields.
33+
34+
### Changed
35+
36+
- **Minimum `netbox-custom-objects` is now 0.5.0.** Enforced at startup:
37+
`PluginConfig.ready()` probes for the `is_polymorphic` model field and
38+
raises `ImproperlyConfigured` with a clear upgrade message if the
39+
installed upstream is older. The check is behaviour-based (looks for the
40+
field directly, not a version string) so it remains correct against forks
41+
and pre-release tags. The pre-0.5 compat shims (`getattr` guards, module
42+
feature probes) have been removed from both `combined.py` and `typed.py`.
43+
44+
### Known Issues
45+
46+
- **Upstream Delete bug on `netbox-custom-objects == 0.5.0` (fixed in 0.5.1).**
47+
Deleting a Custom Object via the NetBox UI on 0.5.0 can raise
48+
`ValueError: Cannot query "...": Must be "Table<N>Model" instance.` from
49+
`CustomObjectDeleteView._get_dependent_objects`. The same crash also hits
50+
`CustomObjectBulkDeleteView` (NetBox's generic `BulkDeleteView.post()`
51+
iterates and calls `obj.delete()` per row — same code path), so
52+
**Bulk Delete is NOT a workaround** (2.3.0 README claim corrected).
53+
**Resolution:** upgrade upstream — PR
54+
[#501](https://github.com/netboxlabs/netbox-custom-objects/pull/501)
55+
(merged into `main` 2026-05-11) eliminates the entire bug class. As of
56+
the 2.4.0 release date, no `0.5.1` release tag exists yet; install from
57+
`main` (`pip install git+...@main`) or pin to `>=0.5.1` once tagged.
58+
Adjacent fixes for related drift paths (#504, #505, #510) also ship in
59+
`main` / `0.5.1`.
60+
- **Polymorphic-MultiObject rows amplify the failure rate on 0.5.0.** Each
61+
polymorphic Object field adds a `GenericForeignKey` descriptor and each
62+
polymorphic MultiObject field adds a per-field through model; Django's
63+
collector traverses every related model during deletion, so polymorphic
64+
rows give the drift more chances to land. Plugin 2.4.0's discovery code
65+
walks these descriptors (the original goal of 2.4.0) and warms the
66+
cache enough that the upstream drift becomes deterministic rather than
67+
intermittent on 0.5.0.
68+
- **Workarounds for installs that cannot upgrade yet:** `manage.py shell`
69+
direct delete (single-process model cache, no drift — see README) or
70+
`systemctl restart netbox` (clears the cache). No UI-side workaround
71+
exists for 0.5.0.
72+
- **Root cause is upstream** (`netbox-custom-objects` dynamic-model caching).
73+
This plugin does not override delete or model caching and cannot fix it
74+
from its own code; PR #501 fixes it inside upstream's delete view.
75+
- **Cosmetic post-fix issue on patched builds.** On builds containing
76+
PR #501 (upstream `main` / forthcoming 0.5.1), the delete-success toast
77+
for some dynamic models renders as `"Deleted <Type> <Type> None"` — the
78+
patched view calls `str(obj)` *after* the row's deletion, so the
79+
primary field returns `None`. Cosmetic only; the delete itself works.
80+
Worth a small upstream follow-up issue but not a blocker.
81+
882
## [2.3.0] - 2026-05-12
983

1084
### Added

README.md

Lines changed: 114 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,26 @@ Two tab modes are available:
2727
## Requirements
2828

2929
- NetBox 4.5.0 – 4.6.99
30-
- `netbox_custom_objects` plugin **≥ 0.4.6** installed and configured (≥ 0.5.0 recommended on NetBox 4.6)
30+
- `netbox_custom_objects` plugin **≥ 0.5.0** installed and configured
31+
(**≥ 0.5.1 strongly recommended** — 0.5.0 has an upstream Delete bug
32+
that 0.5.1 fixes; see [Known Issues](#known-issues))
3133

3234
## Compatibility
3335

34-
| Plugin version | NetBox version | `netbox_custom_objects` version |
35-
|----------------|----------------|---------------------------------------|
36-
| 2.2.x | 4.5.4+ / 4.6.x | ≥ 0.4.6 (≥ 0.5.0 on 4.6) |
37-
| 2.1.x | 4.5.4+ | ≥ 0.4.6 |
38-
| 2.0.x | 4.5.x | ≥ 0.4.6 |
39-
| 1.0.x | 4.5.x | ≥ 0.4.4 |
36+
| Plugin version | NetBox version | `netbox_custom_objects` version |
37+
|----------------|----------------|------------------------------------------------------------------------|
38+
| 2.4.x | 4.5.4+ / 4.6.x | **≥ 0.5.0 required** (≥ 0.5.1 strongly recommended — fixes Delete bug) |
39+
| 2.3.x | 4.5.4+ / 4.6.x | ≥ 0.4.6 (≥ 0.5.0 on 4.6) |
40+
| 2.2.x | 4.5.4+ / 4.6.x | ≥ 0.4.6 (≥ 0.5.0 on 4.6) |
41+
| 2.1.x | 4.5.4+ | ≥ 0.4.6 |
42+
| 2.0.x | 4.5.x | ≥ 0.4.6 |
43+
| 1.0.x | 4.5.x | ≥ 0.4.4 |
44+
45+
Plugin 2.4.x **enforces** the 0.5.0 minimum at startup: `PluginConfig.ready()`
46+
probes for the upstream `is_polymorphic` model field and raises
47+
`ImproperlyConfigured` with an upgrade message if the installed upstream is
48+
older. The check is behaviour-based (looks for the field, not a version
49+
string) so it stays correct across forks and pre-release tags.
4050

4151
## Installation
4252

@@ -210,36 +220,110 @@ The tab displays:
210220

211221
## Known Issues
212222

213-
### Per-row Delete fails on the first attempt right after Create (upstream bug)
223+
### Upstream Delete bug on `netbox-custom-objects == 0.5.0` (fixed in 0.5.1)
214224

215-
After creating a custom object via the 2.3.0 "Add *Type*" button on a
216-
Typed tab, clicking the per-row **Delete** action in the list **on the
217-
very first attempt** raises a `ValueError` inside upstream
225+
**Affected versions:** `netbox-custom-objects == 0.5.0` only.
226+
**Fixed in:** `netbox-custom-objects` `main` (PR
227+
[#501](https://github.com/netboxlabs/netbox-custom-objects/pull/501),
228+
merged 2026-05-11) and the forthcoming `0.5.1` release.
229+
**Not affected:** `0.4.x` (no polymorphic through-models) and any build
230+
that contains PR #501.
231+
232+
Deleting a Custom Object instance through the NetBox UI on a 0.5.0
233+
install can raise a `ValueError` inside
218234
`netbox_custom_objects.CustomObjectDeleteView`:
219235

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

224-
(at `netbox_custom_objects/views.py:977`, inside
225-
`_get_dependent_objects`). Workarounds:
226-
227-
1. **Refresh the typed-tab list page** between clicking Create and
228-
clicking the per-row Delete. The second `/delete/` GET succeeds.
229-
2. **Use Bulk Delete** instead — it goes through a different upstream
230-
code path and is unaffected.
231-
232-
Pre-existing rows (created in earlier sessions or via the upstream
233-
"Add" menu under Custom Objects → *Type*) are not affected. The bug
234-
originates in dynamic-model class identity drift across the
235-
Create → Delete request boundary in the upstream `netbox_custom_objects`
236-
plugin: each Custom Object Type backs a dynamically-generated Django
237-
model (`Table<N>Model`), the model class registry rebuilds during the
238-
Create POST, and the immediately-following Delete GET still holds a
239-
reference to the prior class object in some scope (queryset cache,
240-
prefetch, or import-level reference) until a request boundary refreshes
241-
it. Will be tracked and fixed upstream; this plugin's 2.3.0 release
242-
ships with the workaround documented here.
240+
(at `netbox_custom_objects/views.py:977`, inside `_get_dependent_objects`,
241+
called by Django's `Collector.collect()`). The same crash also occurs from
242+
the bulk-delete view (`CustomObjectBulkDeleteView`) because NetBox's
243+
generic `BulkDeleteView.post()` iterates the queryset and calls `obj.delete()`
244+
per row — the same code path. **Bulk Delete is NOT a workaround**
245+
(earlier versions of this README claimed it was; that was incorrect).
246+
247+
#### Recommended fix — upgrade upstream
248+
249+
The cleanest resolution is to upgrade `netbox-custom-objects` to a
250+
build that contains PR #501. As of writing (2026-05-13) no `0.5.1`
251+
release tag exists yet, so the options are:
252+
253+
```bash
254+
# Option A: install from upstream main (contains PR #501)
255+
pip install --upgrade --force-reinstall \
256+
git+https://github.com/netboxlabs/netbox-custom-objects.git@main
257+
258+
# Option B: wait for the 0.5.1 release tag and pin to it
259+
pip install --upgrade 'netbox-custom-objects>=0.5.1'
260+
```
261+
262+
Then restart NetBox. The entire delete-bug class disappears regardless
263+
of this plugin's state — no plugin-side change required.
264+
265+
Several adjacent fixes also landed in upstream `main` post-0.5.0 and
266+
will ship with `0.5.1`: PR #504 (cross-COT FK fields after restart),
267+
PR #505 (stale through-model FK path_infos on COT regeneration), and
268+
PR #510 (self-referential FK isinstance check). Upgrading once closes
269+
the whole family.
270+
271+
#### Workarounds if you cannot upgrade yet
272+
273+
1. **`manage.py shell` direct delete** (recommended for one-off rows).
274+
A freshly-spawned shell process initialises the model cache exactly
275+
once, so the class identity is consistent throughout the session and
276+
the collector's identity-check succeeds:
277+
```bash
278+
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py shell <<'PY'
279+
from netbox_custom_objects.models import CustomObjectType
280+
cot = CustomObjectType.objects.get(slug="<your-slug>")
281+
cot.get_model().objects.filter(pk=<row-pk>).delete()
282+
PY
283+
```
284+
2. **Refresh the typed-tab list page** between Create and per-row Delete.
285+
This worked reliably for non-polymorphic fields on earlier versions
286+
and still often works on 0.5.0, but it is no longer guaranteed —
287+
polymorphic-MultiObject rows can drift the model cache mid-flow.
288+
3. **Restart NetBox.** Clears `_model_cache` outright. Reliable but
289+
heavyweight; use when shell access isn't available.
290+
291+
#### Why polymorphic fields amplify the bug on 0.5.0
292+
293+
`netbox-custom-objects` 0.5.0 introduced `is_polymorphic=True` Object /
294+
MultiObject fields. Each polymorphic Object field adds a
295+
`GenericForeignKey` descriptor and each polymorphic MultiObject field
296+
adds a per-field through model. Django's collector traverses every
297+
related model when collecting deletion dependencies, so each extra
298+
related-model is another opportunity to hit a stale class generation in
299+
`CustomObjectType._model_cache`. Plugin 2.4.0's discovery code walks
300+
those same descriptors to find inbound links (the original goal of
301+
2.4.0), which warms the cache enough that the upstream drift becomes
302+
deterministic rather than intermittent.
303+
304+
#### Root cause (for the curious)
305+
306+
Each Custom Object Type backs a dynamically-generated Django model
307+
(`Table<N>Model`), and the class registry can rebuild between requests
308+
(or during a request that touches `get_model(no_cache=True)`). Django's
309+
`Collector` then sees the queryset's model class on one side and a
310+
related-field descriptor's `.to` pointing at a *different copy of the
311+
same class name* on the other — its identity check raises `ValueError`.
312+
PR #501 fixes the symptom by overriding
313+
`CustomObjectDeleteView._get_dependent_objects` to filter through-table
314+
entries out of the collector's dependency walk before the identity check
315+
runs. This plugin does not override delete or model caching and cannot
316+
patch the bug from its own code.
317+
318+
#### Cosmetic follow-up on patched builds
319+
320+
On builds that already contain PR #501, the delete-success toast for
321+
some dynamic models renders as `"Deleted <Type> <Type> None"` — the
322+
patched view reads `str(obj)` *after* the row's deletion, so the
323+
dynamic model's primary field returns `None`. Models whose `__str__`
324+
captures the display value before delete are unaffected. This is a
325+
cosmetic, post-fix upstream issue; it does not affect the delete
326+
itself.
243327
244328
## Support
245329

netbox_custom_objects_tab/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ class NetBoxCustomObjectsTabConfig(PluginConfig):
3333

3434
def ready(self):
3535
super().ready()
36+
37+
# Hard gate: require netbox-custom-objects >= 0.5.0. We probe behaviour
38+
# (the `is_polymorphic` model field added in 0.5.0) rather than parsing
39+
# a version string, because forks and pre-release tags can carry any
40+
# version label but either have or lack the field we actually use.
41+
# Raising ImproperlyConfigured here aborts NetBox startup with a clean,
42+
# named error in the logs — preferable to letting a half-loaded plugin
43+
# ImportError mid-request.
44+
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
45+
from netbox_custom_objects.models import CustomObjectTypeField
46+
47+
try:
48+
CustomObjectTypeField._meta.get_field("is_polymorphic")
49+
except FieldDoesNotExist as exc:
50+
raise ImproperlyConfigured(
51+
"netbox-custom-objects-tab 2.4+ requires netbox-custom-objects>=0.5.0. "
52+
"Upgrade with: pip install -U 'netbox-custom-objects>=0.5.0'"
53+
) from exc
54+
3655
from . import template_override, views
3756

3857
template_override.install()

netbox_custom_objects_tab/views/combined.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from urllib.parse import urlencode
44

55
import django_tables2 as tables2
6+
from django.apps import apps
67
from django.contrib.contenttypes.models import ContentType
78
from django.core.paginator import InvalidPage
89
from django.shortcuts import get_object_or_404, render
@@ -43,24 +44,71 @@ class Meta(BaseTable.Meta):
4344

4445

4546
def _iter_linked_fields(instance):
46-
"""Yield (field, model, filter_kwargs) for every CO field referencing instance."""
47+
"""
48+
Yield (field, model, filter_kwargs) for every CO field referencing instance.
49+
50+
Handles both non-polymorphic fields (single related_object_type FK) and
51+
polymorphic fields (related_object_types M2M + is_polymorphic, introduced
52+
in netbox-custom-objects 0.5.0). Mirrors the query shape in upstream's
53+
CustomObjectLink.left_page so behaviour stays consistent with the
54+
upstream "Custom Objects linking to this object" card.
55+
"""
4756
content_type = ContentType.objects.get_for_model(instance._meta.model)
48-
fields = CustomObjectTypeField.objects.filter(
57+
type_choices = [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]
58+
59+
# is_polymorphic=False keeps the two querysets disjoint — a row with
60+
# related_object_type set AND is_polymorphic=True (a legacy misconfig:
61+
# is_polymorphic is immutable upstream but related_object_type isn't
62+
# nulled when toggled) would otherwise be yielded twice.
63+
non_poly = CustomObjectTypeField.objects.filter(
4964
related_object_type=content_type,
50-
type__in=[CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT],
65+
is_polymorphic=False,
66+
type__in=type_choices,
5167
).select_related("custom_object_type")
5268

53-
for field in fields:
69+
poly = CustomObjectTypeField.objects.filter(
70+
related_object_types=content_type,
71+
is_polymorphic=True,
72+
type__in=type_choices,
73+
).select_related("custom_object_type")
74+
75+
for field in list(non_poly) + list(poly):
5476
try:
5577
model = field.custom_object_type.get_model()
5678
except Exception:
5779
logger.exception("Could not get model for CustomObjectType %s", field.custom_object_type_id)
5880
continue
5981

6082
if field.type == CustomFieldTypeChoices.TYPE_OBJECT:
61-
yield field, model, {f"{field.name}_id": instance.pk}
83+
if field.is_polymorphic:
84+
yield (
85+
field,
86+
model,
87+
{
88+
f"{field.name}_content_type_id": content_type.id,
89+
f"{field.name}_object_id": instance.pk,
90+
},
91+
)
92+
else:
93+
yield field, model, {f"{field.name}_id": instance.pk}
6294
elif field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
63-
yield field, model, {field.name: instance.pk}
95+
if field.is_polymorphic:
96+
try:
97+
through = apps.get_model(_CUSTOM_OBJECTS_APP, field.through_model_name)
98+
except LookupError:
99+
logger.exception(
100+
"Could not resolve through model %r for polymorphic field %s",
101+
field.through_model_name,
102+
field.pk,
103+
)
104+
continue
105+
source_ids = through.objects.filter(
106+
content_type_id=content_type.id,
107+
object_id=instance.pk,
108+
).values("source_id")
109+
yield field, model, {"pk__in": source_ids}
110+
else:
111+
yield field, model, {field.name: instance.pk}
64112

65113

66114
def _get_linked_custom_objects(instance):

0 commit comments

Comments
 (0)