Skip to content

Commit 2b9f2b7

Browse files
authored
Synthetic SearchIndex classes for custom objects (#137)
1 parent 3391f6d commit 2b9f2b7

File tree

6 files changed

+197
-31
lines changed

6 files changed

+197
-31
lines changed

netbox_custom_objects/__init__.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,36 @@ def is_running_migration():
1818
return False
1919

2020

21+
def is_in_clear_cache():
22+
"""
23+
Check if the code is currently being called from Django's clear_cache() method.
24+
25+
TODO: This is fairly ugly, but in models.CustomObjectType.get_model() we call
26+
meta = type() which calls clear_cache on the model which causes a call to
27+
get_models() which in-turn calls get_model and therefore recurses.
28+
29+
This catches the specific case of a recursive call to get_models() from
30+
clear_cache() which is the only case we care about, so should be relatively
31+
safe. An alternative should be found for this.
32+
"""
33+
import inspect
34+
35+
frame = inspect.currentframe()
36+
try:
37+
# Walk up the call stack to see if we're being called from clear_cache
38+
while frame:
39+
if (
40+
frame.f_code.co_name == "clear_cache"
41+
and "django/apps/registry.py" in frame.f_code.co_filename
42+
):
43+
return True
44+
frame = frame.f_back
45+
return False
46+
finally:
47+
# Clean up the frame reference
48+
del frame
49+
50+
2151
def check_custom_object_type_table_exists():
2252
"""
2353
Check if the CustomObjectType table exists in the database.
@@ -49,6 +79,7 @@ class CustomObjectsPluginConfig(PluginConfig):
4979
default_settings = {}
5080
required_settings = []
5181
template_extensions = "template_content.template_extensions"
82+
_in_get_models = False # Recursion guard
5283

5384
def get_model(self, model_name, require_ready=True):
5485
try:
@@ -84,33 +115,45 @@ def get_model(self, model_name, require_ready=True):
84115

85116
def get_models(self, include_auto_created=False, include_swapped=False):
86117
"""Return all models for this plugin, including custom object type models."""
118+
87119
# Get the regular Django models first
88120
for model in super().get_models(include_auto_created, include_swapped):
89121
yield model
90122

91-
# Suppress warnings about database calls during model loading
92-
with warnings.catch_warnings():
93-
warnings.filterwarnings(
94-
"ignore", category=RuntimeWarning, message=".*database.*"
95-
)
96-
warnings.filterwarnings(
97-
"ignore", category=UserWarning, message=".*database.*"
98-
)
123+
# Prevent recursion
124+
if self._in_get_models and is_in_clear_cache():
125+
# Skip dynamic model creation if we're in a recursive get_models call
126+
return
99127

100-
# Skip custom object type model loading if running during migration
101-
if is_running_migration() or not check_custom_object_type_table_exists():
102-
return
103-
104-
# Add custom object type models
105-
from .models import CustomObjectType
106-
107-
custom_object_types = CustomObjectType.objects.all()
108-
for custom_type in custom_object_types:
109-
# Only yield already cached models during discovery
110-
if CustomObjectType.is_model_cached(custom_type.id):
111-
model = CustomObjectType.get_cached_model(custom_type.id)
128+
self._in_get_models = True
129+
try:
130+
# Suppress warnings about database calls during model loading
131+
with warnings.catch_warnings():
132+
warnings.filterwarnings(
133+
"ignore", category=RuntimeWarning, message=".*database.*"
134+
)
135+
warnings.filterwarnings(
136+
"ignore", category=UserWarning, message=".*database.*"
137+
)
138+
139+
# Skip custom object type model loading if running during migration
140+
if (
141+
is_running_migration()
142+
or not check_custom_object_type_table_exists()
143+
):
144+
return
145+
146+
# Add custom object type models
147+
from .models import CustomObjectType
148+
149+
custom_object_types = CustomObjectType.objects.all()
150+
for custom_type in custom_object_types:
151+
model = custom_type.get_model()
112152
if model:
113153
yield model
154+
finally:
155+
# Clean up the recursion guard
156+
self._in_get_models = False
114157

115158

116159
config = CustomObjectsPluginConfig

netbox_custom_objects/choices.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,21 @@ class MappingFieldTypeChoices(ChoiceSet):
1818
(DATETIME, _("DateTime"), "blue"),
1919
(OBJECT, _("Object"), "orange"),
2020
)
21+
22+
23+
#
24+
# Search
25+
#
26+
27+
class SearchWeightChoices(ChoiceSet):
28+
WEIGHT_NONE = 0
29+
WEIGHT_LOW = 1000
30+
WEIGHT_MEDIUM = 500
31+
WEIGHT_HIGH = 100
32+
33+
CHOICES = (
34+
(WEIGHT_HIGH, _('High (100)')),
35+
(WEIGHT_MEDIUM, _('Medium (500)')),
36+
(WEIGHT_LOW, _('Low (1000)')),
37+
(WEIGHT_NONE, _('Not searchable')),
38+
)

netbox_custom_objects/forms.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from utilities.forms.rendering import FieldSet
1010
from utilities.object_types import object_type_name
1111

12+
from netbox_custom_objects.choices import SearchWeightChoices
1213
from netbox_custom_objects.constants import APP_LABEL
1314
from netbox_custom_objects.models import (CustomObjectObjectType,
1415
CustomObjectType,
@@ -104,13 +105,21 @@ class CustomObjectTypeFieldForm(CustomFieldForm):
104105
queryset=CustomObjectObjectType.objects.public(),
105106
help_text=_("Type of the related object (for object/multi-object fields only)"),
106107
)
108+
search_weight = forms.ChoiceField(
109+
choices=SearchWeightChoices,
110+
required=False,
111+
help_text=_(
112+
"Weighting for search. Lower values are considered more important. Fields with a search weight of 0 "
113+
"will be ignored."
114+
),
115+
)
107116

108117
fieldsets = (
109118
FieldSet(
110119
"custom_object_type",
111-
"primary",
112120
"name",
113121
"label",
122+
"primary",
114123
"group_name",
115124
"description",
116125
"type",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 5.2.4 on 2025-08-12 17:38
2+
3+
import django.core.validators
4+
import re
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("netbox_custom_objects", "0005_alter_customobjecttype_description"),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="customobjecttypefield",
17+
name="name",
18+
field=models.CharField(
19+
max_length=50,
20+
validators=[
21+
django.core.validators.RegexValidator(
22+
flags=re.RegexFlag["IGNORECASE"],
23+
message="Only alphanumeric characters and underscores are allowed.",
24+
regex="^[a-z0-9_]+$",
25+
),
26+
django.core.validators.RegexValidator(
27+
flags=re.RegexFlag["IGNORECASE"],
28+
inverse_match=True,
29+
message="Double underscores are not permitted in custom object field names.",
30+
regex="__",
31+
),
32+
],
33+
),
34+
),
35+
migrations.AlterField(
36+
model_name="customobjecttypefield",
37+
name="search_weight",
38+
field=models.PositiveSmallIntegerField(default=500),
39+
),
40+
]

netbox_custom_objects/models.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
get_model_features,
4242
)
4343
from netbox.registry import registry
44+
from netbox.search import SearchIndex
4445
from utilities import filters
4546
from utilities.datetime import datetime_from_timestamp
4647
from utilities.object_types import object_type_name
@@ -291,6 +292,7 @@ def get_or_create_content_type(self):
291292
def _fetch_and_generate_field_attrs(
292293
self,
293294
fields,
295+
skip_object_fields=False,
294296
):
295297
field_attrs = {
296298
"_primary_field_id": -1,
@@ -307,6 +309,10 @@ def _fetch_and_generate_field_attrs(
307309

308310
for field in fields:
309311
field_type = FIELD_TYPE_CLASS[field.type]()
312+
if skip_object_fields:
313+
if field.type in [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]:
314+
continue
315+
310316
field_name = field.name
311317

312318
field_attrs["_field_objects"][field.id] = {
@@ -356,11 +362,32 @@ def get_content_type_label(custom_object_type_id):
356362
custom_object_type = CustomObjectType.objects.get(pk=custom_object_type_id)
357363
return f"Custom Objects > {custom_object_type.name}"
358364

365+
def register_custom_object_search_index(self, model):
366+
# model must be an instance of this CustomObjectType's get_model() generated class
367+
fields = []
368+
for field in self.fields.filter(search_weight__gt=0):
369+
fields.append((field.name, field.search_weight))
370+
371+
attrs = {
372+
"model": model,
373+
"fields": tuple(fields),
374+
"display_attrs": tuple(),
375+
}
376+
search_index = type(
377+
f"{self.name}SearchIndex",
378+
(SearchIndex,),
379+
attrs,
380+
)
381+
label = f"{APP_LABEL}.{self.get_table_model_name(self.id).lower()}"
382+
registry["search"][label] = search_index
383+
359384
def get_model(
360385
self,
361386
fields=None,
362387
manytomany_models=None,
363388
app_label=None,
389+
skip_object_fields=False,
390+
no_cache=False,
364391
):
365392
"""
366393
Generates a temporary Django model based on available fields that belong to
@@ -376,12 +403,16 @@ def get_model(
376403
have the same app_label. If passed along in this parameter, then the
377404
generated model will use that one instead of generating a unique one.
378405
:type app_label: Optional[String]
406+
:param skip_object_fields: Don't add object or multiobject fields to the model
407+
:type skip_object_fields: bool
408+
:param no_cache: Don't cache the generated model or attempt to pull from cache
409+
:type no_cache: bool
379410
:return: The generated model.
380411
:rtype: Model
381412
"""
382413

383414
# Check if we have a cached model for this CustomObjectType
384-
if self.is_model_cached(self.id):
415+
if self.is_model_cached(self.id) and not no_cache:
385416
model = self.get_cached_model(self.id)
386417
# Ensure the serializer is registered even for cached models
387418
from netbox_custom_objects.api.serializers import get_serializer_class
@@ -422,7 +453,7 @@ def get_model(
422453
"custom_object_type_id": self.id,
423454
}
424455

425-
field_attrs = self._fetch_and_generate_field_attrs(fields)
456+
field_attrs = self._fetch_and_generate_field_attrs(fields, skip_object_fields=skip_object_fields)
426457

427458
attrs.update(**field_attrs)
428459

@@ -471,14 +502,18 @@ def wrapped_post_through_setup(self, cls):
471502
self._after_model_generation(attrs, model)
472503

473504
# Cache the generated model
474-
self._model_cache[self.id] = model
505+
if not no_cache:
506+
self._model_cache[self.id] = model
475507

476508
# Register the serializer for this model
477509
if not manytomany_models:
478510
from netbox_custom_objects.api.serializers import get_serializer_class
479511

480512
get_serializer_class(model)
481513

514+
# Register the global SearchIndex for this model
515+
self.register_custom_object_search_index(model)
516+
482517
return model
483518

484519
def create_model(self):
@@ -487,7 +522,6 @@ def create_model(self):
487522

488523
# Ensure the ContentType exists and is immediately available
489524
ct = self.get_or_create_content_type()
490-
model = self.get_model()
491525
features = get_model_features(model)
492526
ct.public = True
493527
ct.features = features
@@ -496,6 +530,8 @@ def create_model(self):
496530
with connection.schema_editor() as schema_editor:
497531
schema_editor.create_model(model)
498532

533+
self.register_custom_object_search_index(model)
534+
499535
def save(self, *args, **kwargs):
500536
needs_db_create = self._state.adding
501537
super().save(*args, **kwargs)
@@ -532,9 +568,10 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode
532568
max_length=50,
533569
choices=CustomFieldTypeChoices,
534570
default=CustomFieldTypeChoices.TYPE_TEXT,
535-
help_text=_("The type of data this custom field holds"),
571+
help_text=_("The type of data this custom object field holds"),
536572
)
537573
primary = models.BooleanField(
574+
verbose_name=_("primary name field"),
538575
default=False,
539576
help_text=_(
540577
"Indicates that this field's value will be used as the object's displayed name"
@@ -550,7 +587,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode
550587
name = models.CharField(
551588
verbose_name=_("name"),
552589
max_length=50,
553-
help_text=_("Internal field name"),
590+
help_text=_("Internal field name, e.g. \"vendor_label\""),
554591
validators=(
555592
RegexValidator(
556593
regex=r"^[a-z0-9_]+$",
@@ -560,7 +597,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode
560597
RegexValidator(
561598
regex=r"__",
562599
message=_(
563-
"Double underscores are not permitted in custom field names."
600+
"Double underscores are not permitted in custom object field names."
564601
),
565602
flags=re.IGNORECASE,
566603
inverse_match=True,
@@ -579,7 +616,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode
579616
verbose_name=_("group name"),
580617
max_length=50,
581618
blank=True,
582-
help_text=_("Custom fields within the same group will be displayed together"),
619+
help_text=_("Custom object fields within the same group will be displayed together"),
583620
)
584621
description = models.CharField(
585622
verbose_name=_("description"), max_length=200, blank=True
@@ -598,9 +635,9 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode
598635
)
599636
search_weight = models.PositiveSmallIntegerField(
600637
verbose_name=_("search weight"),
601-
default=1000,
638+
default=500,
602639
help_text=_(
603-
"Weighting for search. Lower values are considered more important. Fields with a search weight of zero "
640+
"Weighting for search. Lower values are considered more important. Fields with a search weight of 0 "
604641
"will be ignored."
605642
),
606643
)
@@ -1274,6 +1311,9 @@ def save(self, *args, **kwargs):
12741311

12751312
super().save(*args, **kwargs)
12761313

1314+
# Reregister SearchIndex with new set of searchable fields
1315+
self.custom_object_type.register_custom_object_search_index(model)
1316+
12771317
def delete(self, *args, **kwargs):
12781318
field_type = FIELD_TYPE_CLASS[self.type]()
12791319
model_field = field_type.get_model_field(self)
@@ -1292,6 +1332,9 @@ def delete(self, *args, **kwargs):
12921332

12931333
super().delete(*args, **kwargs)
12941334

1335+
# Reregister SearchIndex with new set of searchable fields
1336+
self.custom_object_type.register_custom_object_search_index(model)
1337+
12951338

12961339
class CustomObjectObjectTypeManager(ObjectTypeManager):
12971340

0 commit comments

Comments
 (0)