Skip to content

Commit 395eb3a

Browse files
committed
Add LazyForeignKey to handle self-referential single object fields
1 parent ade9bed commit 395eb3a

File tree

3 files changed

+63
-18
lines changed

3 files changed

+63
-18
lines changed

netbox_custom_objects/api/serializers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def get_field_data(self, obj):
206206
return result
207207

208208

209-
def get_serializer_class(model):
209+
def get_serializer_class(model, skip_object_fields=False):
210210
model_fields = model.custom_object_type.fields.all()
211211

212212
# Create field list including all necessary fields
@@ -254,6 +254,8 @@ def get_display(self, obj):
254254
}
255255

256256
for field in model_fields:
257+
if skip_object_fields and field.type in [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]:
258+
continue
257259
field_type = field_types.FIELD_TYPE_CLASS[field.type]()
258260
try:
259261
attrs[field.name] = field_type.get_serializer_field(field)

netbox_custom_objects/field_types.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.contrib.postgres.fields import ArrayField
88
from django.core.validators import RegexValidator
99
from django.db import models
10-
from django.db.models.fields.related import ManyToManyDescriptor
10+
from django.db.models.fields.related import ForeignKey, ManyToManyDescriptor
1111
from django.db.models.manager import Manager
1212
from django.utils.html import escape
1313
from django.utils.safestring import mark_safe
@@ -27,6 +27,31 @@
2727
from netbox_custom_objects.constants import APP_LABEL
2828

2929

30+
class LazyForeignKey(ForeignKey):
31+
"""
32+
A ForeignKey field that can handle lazy model references.
33+
The target model is resolved after the model is fully generated.
34+
"""
35+
36+
def __init__(self, to_model_name, *args, **kwargs):
37+
self._to_model_name = to_model_name
38+
super().__init__(to_model_name, *args, **kwargs)
39+
40+
def contribute_to_class(self, cls, name, **kwargs):
41+
super().contribute_to_class(cls, name, **kwargs)
42+
# Mark this field for later resolution
43+
setattr(cls, f'_resolve_{name}_model', self._resolve_model)
44+
45+
def _resolve_model(self, model):
46+
"""Resolve the lazy reference to the actual model class."""
47+
# Get the actual model class from the app registry
48+
from django.apps import apps
49+
actual_model = apps.get_model(self._to_model_name)
50+
# Update the field's references
51+
self.remote_field.model = actual_model
52+
self.to = actual_model
53+
54+
3055
class FieldType:
3156

3257
def get_display_value(self, instance, field_name):
@@ -332,22 +357,39 @@ def get_model_field(self, field, **kwargs):
332357
to_model = content_type.model
333358
kwargs.update({"default": field.default, "unique": field.unique})
334359

335-
# TODO: Handle pointing to object of same type (avoid infinite loop)
360+
# Handle self-referential fields by using string references
336361
if content_type.app_label == APP_LABEL:
337362
from netbox_custom_objects.models import CustomObjectType
338363

339364
custom_object_type_id = content_type.model.replace("table", "").replace(
340365
"model", ""
341366
)
342367
custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id)
343-
model = custom_object_type.get_model()
368+
369+
# Check if this is a self-referential field
370+
if custom_object_type.id == field.custom_object_type.id:
371+
# For self-referential fields, use LazyForeignKey to defer resolution
372+
model_name = f"{APP_LABEL}.{custom_object_type.get_table_model_name(custom_object_type.id)}"
373+
f = LazyForeignKey(
374+
model_name,
375+
null=True,
376+
blank=True,
377+
on_delete=models.CASCADE,
378+
**kwargs
379+
)
380+
return f
381+
else:
382+
# For cross-referential fields, use skip_object_fields to avoid infinite loops
383+
model = custom_object_type.get_model(skip_object_fields=True)
344384
else:
345385
# to_model = content_type.model_class()._meta.object_name
346386
to_ct = f"{content_type.app_label}.{to_model}"
347387
model = apps.get_model(to_ct)
388+
348389
f = models.ForeignKey(
349390
model, null=True, blank=True, on_delete=models.CASCADE, **kwargs
350391
)
392+
351393
return f
352394

353395
def get_form_field(self, field, for_csv_import=False, **kwargs):
@@ -404,11 +446,23 @@ def render_table_column(self, value):
404446

405447
def get_serializer_field(self, field, **kwargs):
406448
related_model_class = field.related_object_type.model_class()
407-
if not related_model_class:
408-
raise NotImplementedError("Custom object serializers not implemented")
409-
serializer = get_serializer_for_model(related_model_class)
449+
if related_model_class._meta.app_label == APP_LABEL:
450+
from netbox_custom_objects.api.serializers import get_serializer_class
451+
serializer = get_serializer_class(related_model_class, skip_object_fields=True)
452+
else:
453+
serializer = get_serializer_for_model(related_model_class)
410454
return serializer(required=field.required, nested=True)
411455

456+
def after_model_generation(self, instance, model, field_name):
457+
"""
458+
Resolve lazy references after the model is fully generated.
459+
This ensures that self-referential fields point to the correct model class.
460+
"""
461+
# Check if this field has a resolution method
462+
resolve_method = getattr(model, f'_resolve_{field_name}_model', None)
463+
if resolve_method:
464+
resolve_method(model)
465+
412466

413467
class CustomManyToManyManager(Manager):
414468
def __init__(self, instance=None, field_name=None):

netbox_custom_objects/forms.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,17 +172,6 @@ def __init__(self, *args, **kwargs):
172172
if "related_object_type" in self.fields:
173173
self.fields["related_object_type"].disabled = True
174174

175-
def clean_related_object_type(self):
176-
# TODO: Figure out how to do recursive M2M relations and remove this constraint
177-
if (
178-
self.cleaned_data["related_object_type"]
179-
== self.cleaned_data["custom_object_type"].content_type
180-
):
181-
raise forms.ValidationError(
182-
"Cannot create a foreign-key relation with custom objects of the same type."
183-
)
184-
return self.cleaned_data["related_object_type"]
185-
186175
def clean_primary(self):
187176
primary_fields = self.cleaned_data["custom_object_type"].fields.filter(
188177
primary=True

0 commit comments

Comments
 (0)