Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8cb7484
Polymorphic object/multiobject fields
bctiemann Apr 1, 2026
1a82d0b
Improve input fields in edit forms
bctiemann Apr 1, 2026
aa1f306
Add unit tests for UI and API
bctiemann Apr 2, 2026
9091e7c
Make COTF add form responsive to polymorphic selection
bctiemann Apr 2, 2026
75324de
Remove "polymorphic" field from inapplicable field types in form
bctiemann Apr 2, 2026
bdcc2ec
Cleanup
bctiemann Apr 2, 2026
165b56c
Cleanup
bctiemann Apr 2, 2026
58438bb
Address review issues and feedback
bctiemann Apr 2, 2026
9f436d4
Address review issues and feedback
bctiemann Apr 2, 2026
215787e
Address review issues and feedback
bctiemann Apr 2, 2026
0f88cd6
Address review issues and feedback
bctiemann Apr 2, 2026
a3e9ddb
Fix recursion in get_model
bctiemann Apr 2, 2026
3afb461
Fix deletion test
bctiemann Apr 2, 2026
ea45e50
Fix deletion test
bctiemann Apr 2, 2026
5ce0d04
Address review issues
bctiemann Apr 2, 2026
8cc48df
Fix dummy assignment
bctiemann Apr 2, 2026
153b045
Address code issues
bctiemann Apr 2, 2026
3e260b4
Address code issues
bctiemann Apr 2, 2026
f4d30c8
Ruff fix
bctiemann Apr 2, 2026
1ca52fd
Address code/security issues
bctiemann Apr 2, 2026
5676daf
Address code/security issues
bctiemann Apr 2, 2026
beacff8
Address code/security issues
bctiemann Apr 2, 2026
a7c8eba
Update docstring
bctiemann Apr 2, 2026
34e748a
Merge origin/feature into 31-polymorphic-object-fields
bctiemann Apr 21, 2026
dc34bbe
Remove suppress_clear_cache from __all__ (renamed to _suppress_clear_…
bctiemann Apr 21, 2026
00e9a43
Fix duplicate import and unused import introduced by merge
bctiemann Apr 21, 2026
6433624
Internationalize error strings in PolymorphicObjectSerializerField
bctiemann Apr 21, 2026
72284ee
Reject related_name on polymorphic fields in clean()
bctiemann Apr 21, 2026
b90b528
Internationalize remaining ValidationError strings in serializers.py
bctiemann Apr 21, 2026
896870b
tests: add API DELETE tests for custom objects with polymorphic fields
bctiemann Apr 21, 2026
4d2378e
Ruff fix
bctiemann Apr 21, 2026
d46fa38
Merge origin/feature into 31-polymorphic-object-fields
bctiemann Apr 22, 2026
7a07e58
Fix stale through-model FK causing ValueError on custom object delete
bctiemann Apr 22, 2026
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
329 changes: 285 additions & 44 deletions netbox_custom_objects/api/serializers.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions netbox_custom_objects/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def get_view_name(self):


class CustomObjectTypeViewSet(ModelViewSet):
queryset = CustomObjectType.objects.all()
queryset = CustomObjectType.objects.prefetch_related('fields__related_object_types')
serializer_class = serializers.CustomObjectTypeSerializer


Expand Down Expand Up @@ -120,7 +120,7 @@ def perform_destroy(self, instance):


class CustomObjectTypeFieldViewSet(ModelViewSet):
queryset = CustomObjectTypeField.objects.all()
queryset = CustomObjectTypeField.objects.prefetch_related('related_object_types')
serializer_class = serializers.CustomObjectTypeFieldSerializer


Expand Down
501 changes: 484 additions & 17 deletions netbox_custom_objects/field_types.py

Large diffs are not rendered by default.

140 changes: 126 additions & 14 deletions netbox_custom_objects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
from netbox.forms import (NetBoxModelBulkEditForm, NetBoxModelFilterSetForm,
NetBoxModelForm, NetBoxModelImportForm)
from utilities.forms.fields import (CommentField, ContentTypeChoiceField,
ContentTypeMultipleChoiceField,
DynamicModelChoiceField, SlugField, TagFilterField)
from utilities.forms.rendering import FieldSet
from utilities.forms.utils import get_field_value
from utilities.object_types import object_type_name

from netbox_custom_objects.choices import SearchWeightChoices
Expand Down Expand Up @@ -121,6 +123,25 @@ def label_from_instance(self, obj):
return super().label_from_instance(obj)


class CustomContentTypeMultipleChoiceField(ContentTypeMultipleChoiceField):
"""Multi-select version of CustomContentTypeChoiceField for polymorphic object fields."""

def label_from_instance(self, obj):
if obj.app_label == APP_LABEL:
custom_object_type_id = extract_cot_id_from_model_name(obj.model)
if custom_object_type_id is not None:
try:
return CustomObjectType.get_content_type_label(
custom_object_type_id
)
except CustomObjectType.DoesNotExist:
pass
try:
return object_type_name(obj)
except AttributeError:
return super().label_from_instance(obj)


class CustomObjectTypeFieldForm(CustomFieldForm):
# This field should be removed or at least "required" should be defeated
object_types = forms.CharField(
Expand All @@ -136,7 +157,17 @@ class CustomObjectTypeFieldForm(CustomFieldForm):
related_object_type = CustomContentTypeChoiceField(
label=_("Related object type"),
queryset=CustomObjectObjectType.objects.public(),
help_text=_("Type of the related object (for object/multi-object fields only)"),
required=False,
help_text=_("Type of the related object (for non-polymorphic object/multi-object fields)"),
)
related_object_types = CustomContentTypeMultipleChoiceField(
label=_("Related object types"),
queryset=CustomObjectObjectType.objects.public(),
required=False,
help_text=_(
"Allowed object types for a polymorphic field (select one or more). "
"Only used when 'Polymorphic' is enabled."
),
)
search_weight = forms.ChoiceField(
choices=SearchWeightChoices,
Expand All @@ -162,6 +193,13 @@ class CustomObjectTypeFieldForm(CustomFieldForm):
"default",
name=_("Field"),
),
FieldSet(
"is_polymorphic",
"related_object_type",
"related_object_types",
"related_object_filter",
name=_("Related Object"),
),
FieldSet(
"search_weight",
"filter_logic",
Expand All @@ -180,14 +218,74 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# Disable changing the custom object type or related object type of a field
# Toggling the polymorphic checkbox should re-render the form so only the
# relevant related-object field is shown.
self.fields['is_polymorphic'].widget.attrs.update({
'hx-get': '.',
'hx-include': '#form_fields',
'hx-target': '#form_fields',
})

# Determine current field type and polymorphic state.
# For existing instances is_polymorphic cannot be changed, so read it from the
# instance directly; for new fields use whatever the form currently carries.
field_type = get_field_value(self, 'type')
if self.instance.pk:
is_polymorphic = self.instance.is_polymorphic
elif self.is_bound:
# get_field_value() falls back to initial for BooleanField (no valid_value);
# read the submitted checkbox value from self.data directly instead.
is_polymorphic = bool(self.data.get('is_polymorphic'))
else:
is_polymorphic = bool(get_field_value(self, 'is_polymorphic'))

# Show only the relevant related-object field and rebuild fieldsets cleanly.
# The parent __init__ inserts a simple FieldSet('related_object_type', ...) for
# object/multiobject types, which would create a duplicate section; replacing
# self.fieldsets here keeps a single "Related Object" group.
if field_type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
if is_polymorphic:
if 'related_object_type' in self.fields:
del self.fields['related_object_type']
related_obj_fields = ('is_polymorphic', 'related_object_types', 'related_object_filter')
else:
if 'related_object_types' in self.fields:
del self.fields['related_object_types']
related_obj_fields = ('is_polymorphic', 'related_object_type', 'related_object_filter')
self.fieldsets = (
CustomObjectTypeFieldForm.fieldsets[0],
FieldSet(*related_obj_fields, name=_('Related Object')),
CustomObjectTypeFieldForm.fieldsets[2],
)
else:
# Parent already removed related_object_type/related_object_filter;
# remove the remaining related-object fields too.
for fname in ('related_object_types', 'is_polymorphic'):
if fname in self.fields:
del self.fields[fname]
# Drop the Related Object fieldset entirely so no empty section header renders.
# Filter by checking that every item in a fieldset belongs to the related-object
# field set (handles both our full FieldSet and any parent-inserted simple one).
_related_names = frozenset({
'is_polymorphic', 'related_object_type', 'related_object_types', 'related_object_filter',
})
self.fieldsets = tuple(
fs for fs in self.fieldsets
if not all(isinstance(item, str) and item in _related_names for item in fs.items)
)

# Disable immutable fields on existing instances.
if self.instance.pk:
self.fields["custom_object_type"].disabled = True
if "related_object_type" in self.fields:
if 'is_polymorphic' in self.fields:
self.fields["is_polymorphic"].disabled = True
if 'related_object_types' in self.fields:
self.fields["related_object_types"].disabled = True
if 'related_object_type' in self.fields:
self.fields["related_object_type"].disabled = True

# Multi-object fields may not be set unique
if self.initial["type"] == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
if field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
self.fields["unique"].disabled = True

# Add related_name to the Related Object fieldset for object/multiobject fields.
Expand All @@ -203,26 +301,40 @@ def __init__(self, *args, **kwargs):
else:
del self.fields["related_name"]

def clean(self):
cleaned_data = super().clean()
field_type = cleaned_data.get("type")
is_polymorphic = cleaned_data.get("is_polymorphic", False)

if field_type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
) and is_polymorphic:
related_object_types = cleaned_data.get("related_object_types")
if not related_object_types:
self.add_error(
"related_object_types",
_("Polymorphic object fields must specify at least one related object type."),
)

return cleaned_data

def clean_primary(self):
primary_fields = self.cleaned_data["custom_object_type"].fields.filter(
primary=True
)
if self.cleaned_data["primary"]:
primary_fields.update(primary=False)
# It should be possible to have NO primary fields set on an object, and thus for its name to appear
# as the default of e.g. "Cat 1"; therefore don't try to guarantee that a primary is set
# else:
# if self.instance:
# other_primary_fields = primary_fields.exclude(pk=self.instance.id)
# else:
# other_primary_fields = primary_fields
# if not other_primary_fields.exists():
# return True
return self.cleaned_data["primary"]

def save(self, commit=True):
obj = super().save(commit=commit)
if obj.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT and obj.default:
# For polymorphic multiobject fields, skip default value propagation
if (
not obj.is_polymorphic
and obj.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
and obj.default
):
qs = obj.related_object_type.model_class().objects.filter(
pk__in=obj.default
)
Expand Down
38 changes: 38 additions & 0 deletions netbox_custom_objects/migrations/0007_polymorphic_object_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0018_concrete_objecttype"),
("netbox_custom_objects", "0006_customobjecttypefield_related_name_and_more"),
]

operations = [
migrations.AddField(
model_name="customobjecttypefield",
name="is_polymorphic",
field=models.BooleanField(
default=False,
verbose_name="polymorphic",
help_text=(
"When enabled, this field uses a generic foreign key and may reference "
"objects of multiple types. Set the allowed types in 'Related object types'."
),
),
),
migrations.AddField(
model_name="customobjecttypefield",
name="related_object_types",
field=models.ManyToManyField(
blank=True,
related_name="polymorphic_custom_object_type_fields",
to="core.objecttype",
verbose_name="related object types",
help_text=(
"The types of objects this polymorphic field may reference "
"(used when 'Polymorphic' is enabled)."
),
),
),
]
Loading
Loading