Skip to content

Commit a9b372f

Browse files
authored
Merge pull request #485 from netboxlabs/471-object-field-on-delete-behavior
Closes: #471 - selectable on_delete behavior for Object-type fields
2 parents 2c8a904 + 57d366d commit a9b372f

18 files changed

Lines changed: 766 additions & 58 deletions

netbox_custom_objects/api/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class Meta:
6868
"related_object_type",
6969
"related_object_filter",
7070
"related_name",
71+
"on_delete_behavior",
7172
"app_label",
7273
"model",
7374
"group_name",
@@ -123,8 +124,19 @@ def validate(self, attrs):
123124
raise ValidationError(
124125
"Must provide choice_set with valid PK for select field type."
125126
)
127+
on_delete = attrs.get("on_delete_behavior")
128+
if on_delete and field_type and field_type != CustomFieldTypeChoices.TYPE_OBJECT:
129+
raise ValidationError(
130+
{"on_delete_behavior": "on_delete_behavior can only be set for Object-type fields."}
131+
)
126132
return super().validate(attrs)
127133

134+
def to_representation(self, instance):
135+
data = super().to_representation(instance)
136+
if instance.type != CustomFieldTypeChoices.TYPE_OBJECT:
137+
data['on_delete_behavior'] = None
138+
return data
139+
128140
def create(self, validated_data):
129141
"""
130142
Record the user who created the Custom Object as its owner.

netbox_custom_objects/choices.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22
from utilities.choices import ChoiceSet
33

44

5+
class ObjectFieldOnDeleteChoices(ChoiceSet):
6+
"""Controls what happens to a Custom Object when the referenced object is deleted."""
7+
CASCADE = "cascade"
8+
SET_NULL = "set_null"
9+
PROTECT = "protect"
10+
11+
CHOICES = (
12+
(SET_NULL, _("Set null (clear the field, keep this object)")),
13+
(CASCADE, _("Cascade (delete this object too)")),
14+
(PROTECT, _("Protect (prevent deletion of the referenced object)")),
15+
)
16+
17+
518
class MappingFieldTypeChoices(ChoiceSet):
619
CHAR = "char"
720
INTEGER = "integer"

netbox_custom_objects/field_types.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from utilities.templatetags.builtins.filters import linkify, render_markdown
3030
from netbox.tables.columns import BooleanColumn
3131

32+
from netbox_custom_objects.choices import ObjectFieldOnDeleteChoices
3233
from netbox_custom_objects.constants import APP_LABEL
3334
from netbox_custom_objects.utilities import extract_cot_id_from_model_name, generate_model
3435

@@ -486,6 +487,13 @@ def render_table_column(self, value):
486487
return ", ".join(value)
487488

488489

490+
_ON_DELETE_MAP = {
491+
ObjectFieldOnDeleteChoices.CASCADE: models.CASCADE,
492+
ObjectFieldOnDeleteChoices.SET_NULL: models.SET_NULL,
493+
ObjectFieldOnDeleteChoices.PROTECT: models.PROTECT,
494+
}
495+
496+
489497
class ObjectFieldType(FieldType):
490498
def get_model_field(self, field, **kwargs):
491499
content_type = self._get_related_content_type(field)
@@ -495,6 +503,11 @@ def get_model_field(self, field, **kwargs):
495503
field_kwargs = {k: v for k, v in kwargs.items() if not k.startswith('_')}
496504
field_kwargs.update({"default": field.default, "unique": field.unique})
497505

506+
on_delete = _ON_DELETE_MAP.get(
507+
getattr(field, 'on_delete_behavior', None) or ObjectFieldOnDeleteChoices.SET_NULL,
508+
models.SET_NULL,
509+
)
510+
498511
# Handle self-referential fields by using string references
499512
if content_type.app_label == APP_LABEL:
500513
from netbox_custom_objects.models import CustomObjectType
@@ -522,7 +535,7 @@ def get_model_field(self, field, **kwargs):
522535
model_name,
523536
null=True,
524537
blank=True,
525-
on_delete=models.SET_NULL,
538+
on_delete=on_delete,
526539
related_name=related_name,
527540
**field_kwargs
528541
)
@@ -542,7 +555,7 @@ def get_model_field(self, field, **kwargs):
542555
table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower()
543556
related_name = f"{table_model_name}_{field.name}_set"
544557
f = models.ForeignKey(
545-
model, null=True, blank=True, on_delete=models.SET_NULL, related_name=related_name, **field_kwargs
558+
model, null=True, blank=True, on_delete=on_delete, related_name=related_name, **field_kwargs
546559
)
547560

548561
return f

netbox_custom_objects/forms.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from utilities.forms.fields import (CommentField, ContentTypeChoiceField,
88
DynamicModelChoiceField, SlugField, TagFilterField)
99
from utilities.forms.rendering import FieldSet
10+
from utilities.forms.utils import get_field_value
1011
from utilities.object_types import object_type_name
1112

1213
from netbox_custom_objects.choices import SearchWeightChoices
@@ -187,21 +188,28 @@ def __init__(self, *args, **kwargs):
187188
self.fields["related_object_type"].disabled = True
188189

189190
# Multi-object fields may not be set unique
190-
if self.initial["type"] == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
191+
if get_field_value(self, 'type') == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
191192
self.fields["unique"].disabled = True
192193

193-
# Add related_name to the Related Object fieldset for object/multiobject fields.
194-
# The parent CustomFieldForm.__init__ removes related_object_type from self.fields
195-
# for non-object types, so we use its presence as a signal.
194+
# Add related_name (and on_delete_behavior for single-object fields) to the
195+
# Related Object fieldset. The parent CustomFieldForm.__init__ removes
196+
# related_object_type from self.fields for non-object types, so we use its
197+
# presence as a signal.
196198
if "related_object_type" in self.fields:
199+
field_type = get_field_value(self, 'type')
200+
is_single_object = field_type == CustomFieldTypeChoices.TYPE_OBJECT
201+
extra = ("related_name", "on_delete_behavior") if is_single_object else ("related_name",)
197202
self.fieldsets = tuple(
198-
FieldSet(*fs.items, "related_name", name=fs.name)
203+
FieldSet(*fs.items, *extra, name=fs.name)
199204
if "related_object_type" in fs.items
200205
else fs
201206
for fs in self.fieldsets
202207
)
208+
if not is_single_object:
209+
del self.fields["on_delete_behavior"]
203210
else:
204211
del self.fields["related_name"]
212+
del self.fields["on_delete_behavior"]
205213

206214
def clean_primary(self):
207215
primary_fields = self.cleaned_data["custom_object_type"].fields.filter(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
("netbox_custom_objects", "0010_fix_object_fk_set_null"),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name="customobjecttypefield",
13+
name="on_delete_behavior",
14+
field=models.CharField(
15+
blank=True,
16+
choices=[
17+
("set_null", "Set null (clear the field, keep this object)"),
18+
("cascade", "Cascade (delete this object too)"),
19+
("protect", "Protect (prevent deletion of the referenced object)"),
20+
],
21+
default="set_null",
22+
help_text=(
23+
"What happens to this Custom Object when the referenced object is deleted "
24+
"(applies to Object-type fields only). "
25+
"Set null: clear the field and keep this object. "
26+
"Cascade: delete this object too. "
27+
"Protect: prevent deletion of the referenced object."
28+
),
29+
max_length=20,
30+
verbose_name="on delete behavior",
31+
),
32+
),
33+
]

0 commit comments

Comments
 (0)